From b19d442daa2f54b580cd8012c211d961a27a9c83 Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Fri, 24 Jan 2025 11:26:28 -0300 Subject: [PATCH 1/7] bug: [ON-3296] add link in invalid invitation page --- .../[lng]/user/invites/InviteErrorView.tsx | 25 ++++++++++++++++--- app/src/components/Texts/Body.tsx | 1 + app/src/i18n/locales/de/not-found.json | 7 +++--- app/src/i18n/locales/en/not-found.json | 3 ++- app/src/i18n/locales/es/not-found.json | 3 ++- app/src/i18n/locales/pt/not-found.json | 3 ++- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/src/app/[lng]/user/invites/InviteErrorView.tsx b/app/src/app/[lng]/user/invites/InviteErrorView.tsx index af636480c..4de0d1326 100644 --- a/app/src/app/[lng]/user/invites/InviteErrorView.tsx +++ b/app/src/app/[lng]/user/invites/InviteErrorView.tsx @@ -1,13 +1,13 @@ "use client"; -import { BodyXLarge } from "@/components/Texts/Body"; import { DisplaySmall } from "@/components/Texts/Display"; import { useTranslation } from "@/i18n/client"; import { ArrowForwardIcon } from "@chakra-ui/icons"; -import { Box } from "@chakra-ui/layout"; +import { Box, Link } from "@chakra-ui/layout"; import { Button, Center } from "@chakra-ui/react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import React from "react"; +import { BodyXLarge } from "@/components/Texts/Body"; interface InviteErrorViewProps { lng: string; @@ -39,7 +39,25 @@ const InviteErrorView = ({ lng }: InviteErrorViewProps) => { >
- + + {t("invite-not-valid-description")}{" "} + + {t("contact-us")} + {" "} +
+ ; diff --git a/app/src/components/Texts/Body.tsx b/app/src/components/Texts/Body.tsx index fde2c4bc3..e84c15c42 100644 --- a/app/src/components/Texts/Body.tsx +++ b/app/src/components/Texts/Body.tsx @@ -14,6 +14,7 @@ export const BodyXLarge = ({ text, ...props }: BodyProps) => ( {...props} > {text} + {props.children} ); diff --git a/app/src/i18n/locales/de/not-found.json b/app/src/i18n/locales/de/not-found.json index b10c460e9..9e1a87f3c 100644 --- a/app/src/i18n/locales/de/not-found.json +++ b/app/src/i18n/locales/de/not-found.json @@ -1,7 +1,8 @@ { "not-found-description": "Scheint, als wären wir in den Emissionsnebel geraten. Bitte kehren Sie zum Dashboard zurück", "goto-dashboard": "Gehe zum Dashboard", - "invite-not-valid": "Este convite não é mais válido", - "invite-not-valid-description": "O link que você está usando está expirado ou já foi reivindicado.\n Por favor, peça ao convidador para reenviá-lo para você.\n Se você acha que isso é um erro, por favor, entre em contato conosco.", - "go-back": "Voltar" + "invite-not-valid": "Diese Einladung ist nicht mehr gültig", + "invite-not-valid-description": "Der Link, den Sie verwenden, ist entweder abgelaufen oder wurde bereits eingelöst.\n Bitte bitten Sie den Einladenden, ihn Ihnen erneut zu senden.\n Wenn Sie denken, dass dies ein Fehler ist, bitte", + "contact-us": "kontaktieren Sie uns.", + "go-back": "Zurück" } diff --git a/app/src/i18n/locales/en/not-found.json b/app/src/i18n/locales/en/not-found.json index 7a99e65c6..f76129d2a 100644 --- a/app/src/i18n/locales/en/not-found.json +++ b/app/src/i18n/locales/en/not-found.json @@ -2,6 +2,7 @@ "not-found-description": "Seems like we've wandered into the emission mist. Please go back to the dashboard", "goto-dashboard": "Go to dashboard", "invite-not-valid": "This invitation is not valid anymore", - "invite-not-valid-description": "The link you are using is either expired or was already claimed.\n Please ask the inviter to resend it to you.\n If you think this is a mistake, please contact us.", + "invite-not-valid-description": "The link you are using is either expired or was already claimed.\n Please ask the inviter to resend it to you.\n If you think this is a mistake, please", + "contact-us": "contact us.", "go-back": "Go back" } diff --git a/app/src/i18n/locales/es/not-found.json b/app/src/i18n/locales/es/not-found.json index 1f63fe79b..797818cd2 100644 --- a/app/src/i18n/locales/es/not-found.json +++ b/app/src/i18n/locales/es/not-found.json @@ -2,6 +2,7 @@ "not-found-description": "Parece que nos hemos adentrado en la niebla de emisiones. Por favor, regresa al panel de control", "goto-dashboard": "Ir al tablero", "invite-not-valid": "Esta invitación ya no es válida", - "invite-not-valid-description": "El enlace que estás usando ha expirado o ya fue reclamado.\n Por favor, pide al invitador que te lo reenvíe.\n Si crees que esto es un error, por favor contáctanos.", + "invite-not-valid-description": "El enlace que estás usando ha expirado o ya fue reclamado.\n Por favor, pide al invitador que te lo reenvíe.\n Si crees que esto es un error, por favor", + "contact-us": "contáctanos.", "go-back": "Regresar" } \ No newline at end of file diff --git a/app/src/i18n/locales/pt/not-found.json b/app/src/i18n/locales/pt/not-found.json index 8f360223c..cdfbb7c1c 100644 --- a/app/src/i18n/locales/pt/not-found.json +++ b/app/src/i18n/locales/pt/not-found.json @@ -2,6 +2,7 @@ "not-found-description": "Parece que nos perdemos na névoa das emissões. Por favor, volte para o painel", "goto-dashboard": "Ir para o painel", "invite-not-valid": "Este convite não é mais válido", - "invite-not-valid-description": "O link que você está usando está expirado ou já foi reivindicado.\n Por favor, peça ao convidador para reenviá-lo para você.\n Se você acha que isso é um erro, por favor, entre em contato conosco.", + "invite-not-valid-description": "O link que você está usando está expirado ou já foi reivindicado.\n Por favor, peça ao convidador para reenviá-lo para você.\n Se você acha que isso é um erro, por favor,", + "contact-us": "entre em contato conosco.", "go-back": "Voltar" } From d23fbfebdd7141385dafe1eafdbc091c55c33c54 Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Thu, 30 Jan 2025 16:18:33 -0300 Subject: [PATCH 2/7] feat: [ON-3245] move roles to types.ts --- app/scripts/create-admin.ts | 2 +- app/src/app/api/v0/assistants/route.ts | 3 ++- app/src/app/api/v0/auth/register/route.ts | 2 +- app/src/app/api/v0/auth/role/route.ts | 2 +- .../app/api/v0/city/[city]/user/[user]/route.ts | 3 ++- app/src/backend/UserService.ts | 3 ++- app/src/i18n/locales/de/settings.json | 2 +- app/src/i18n/locales/en/settings.json | 2 +- app/src/i18n/locales/es/settings.json | 2 +- app/src/i18n/locales/pt/settings.json | 2 +- app/src/lib/auth.ts | 8 ++------ app/src/models/User.ts | 6 ++++-- app/src/util/types.ts | 6 ++++++ app/tests/api/admin.jest.ts | 17 ++++++++++++----- app/tests/api/city.jest.ts | 10 +++++----- app/tests/api/datasource.jest.ts | 3 ++- app/tests/helpers.ts | 3 ++- 17 files changed, 46 insertions(+), 30 deletions(-) diff --git a/app/scripts/create-admin.ts b/app/scripts/create-admin.ts index e63090772..999c00b3b 100644 --- a/app/scripts/create-admin.ts +++ b/app/scripts/create-admin.ts @@ -3,7 +3,7 @@ import env from "@next/env"; import { randomUUID } from "node:crypto"; import { logger } from "@/services/logger"; import bcrypt from "bcrypt"; -import { Roles } from "@/lib/auth"; +import { Roles } from "@/util/types"; async function createAdmin() { const projectDir = process.cwd(); diff --git a/app/src/app/api/v0/assistants/route.ts b/app/src/app/api/v0/assistants/route.ts index cb1ba5b2c..fcb7a48a9 100644 --- a/app/src/app/api/v0/assistants/route.ts +++ b/app/src/app/api/v0/assistants/route.ts @@ -1,7 +1,8 @@ import { apiHandler } from "@/util/api"; import { setupOpenAI } from "@/util/openai"; import { NextResponse } from "next/server"; -import { Roles } from "@/lib/auth"; + +import { Roles } from "@/util/types"; // Create a new assistant export const POST = apiHandler(async (_req, { session }) => { diff --git a/app/src/app/api/v0/auth/register/route.ts b/app/src/app/api/v0/auth/register/route.ts index 2ee2f3f3b..baa39a664 100644 --- a/app/src/app/api/v0/auth/register/route.ts +++ b/app/src/app/api/v0/auth/register/route.ts @@ -1,10 +1,10 @@ -import { Roles } from "@/lib/auth"; import { db } from "@/models"; import { apiHandler } from "@/util/api"; import { signupRequest } from "@/util/validation"; import bcrypt from "bcrypt"; import { NextResponse } from "next/server"; import { randomUUID } from "node:crypto"; +import { Roles } from "@/util/types"; export const POST = apiHandler(async (req: Request) => { const body = signupRequest.parse(await req.json()); diff --git a/app/src/app/api/v0/auth/role/route.ts b/app/src/app/api/v0/auth/role/route.ts index 97fb865ac..15581c1f5 100644 --- a/app/src/app/api/v0/auth/role/route.ts +++ b/app/src/app/api/v0/auth/role/route.ts @@ -1,4 +1,4 @@ -import { Roles } from "@/lib/auth"; +import { Roles } from "@/util/types"; import { db } from "@/models"; import { apiHandler } from "@/util/api"; import createHttpError from "http-errors"; diff --git a/app/src/app/api/v0/city/[city]/user/[user]/route.ts b/app/src/app/api/v0/city/[city]/user/[user]/route.ts index 738c4f0df..67005c9a2 100644 --- a/app/src/app/api/v0/city/[city]/user/[user]/route.ts +++ b/app/src/app/api/v0/city/[city]/user/[user]/route.ts @@ -4,6 +4,7 @@ import { apiHandler } from "@/util/api"; import createHttpError from "http-errors"; import { NextResponse } from "next/server"; import { z } from "zod"; +import { Roles } from "@/util/types"; export const GET = apiHandler(async (_req, { params, session }) => { const user = await UserService.findUser(params.user, session, { @@ -15,7 +16,7 @@ export const GET = apiHandler(async (_req, { params, session }) => { const updateUserRequest = z.object({ name: z.string(), - role: z.string(), + role: z.nativeEnum(Roles), }); export const PATCH = apiHandler(async (_req, { params, session }) => { diff --git a/app/src/backend/UserService.ts b/app/src/backend/UserService.ts index 996f08bfc..126d02285 100644 --- a/app/src/backend/UserService.ts +++ b/app/src/backend/UserService.ts @@ -1,7 +1,8 @@ import { db } from "@/models"; import createHttpError from "http-errors"; -import { Roles, type AppSession } from "@/lib/auth"; +import { Roles } from "@/util/types"; +import { type AppSession } from "@/lib/auth"; import type { City } from "@/models/City"; import type { Inventory } from "@/models/Inventory"; import type { User } from "@/models/User"; diff --git a/app/src/i18n/locales/de/settings.json b/app/src/i18n/locales/de/settings.json index 04e4fae6a..29d51641d 100644 --- a/app/src/i18n/locales/de/settings.json +++ b/app/src/i18n/locales/de/settings.json @@ -23,7 +23,7 @@ "all": "Hinzufügen", "role": "Rolle", "admin": "Administrator", - "contributor": "Mitwirkender", + "user": "Mitwirkender", "save-changes": "Änderungen speichern", "my-files-sub-title": "Hier finden Sie alle Ihre hochgeladenen Dateien, ausstehende Anfragen zur Harmonisierung und im Treibhausgasinventar enthaltene Dateien", "all-inventory-years": "alle Inventarjahre", diff --git a/app/src/i18n/locales/en/settings.json b/app/src/i18n/locales/en/settings.json index 4c1a0b1ae..3d29c4df0 100644 --- a/app/src/i18n/locales/en/settings.json +++ b/app/src/i18n/locales/en/settings.json @@ -23,7 +23,7 @@ "email": "Email", "role": "Role", "admin": "Admin", - "contributor": "Contributor", + "user": "Contributor", "save-changes": "Save Changes", "my-files-sub-title": "Here you can find all your files uploaded, pending request harmonization, and included in the GHG Inventory", "all-inventory-years": "all inventory years", diff --git a/app/src/i18n/locales/es/settings.json b/app/src/i18n/locales/es/settings.json index c6ed81338..62457e6e9 100644 --- a/app/src/i18n/locales/es/settings.json +++ b/app/src/i18n/locales/es/settings.json @@ -19,7 +19,7 @@ "state-province": "Estado / Provincia", "country": "País", "admin": "Administrador", - "contributor": "Colaborador", + "user": "Colaborador", "name": "Nombre", "all": "Todos", "select": "Seleccionar", diff --git a/app/src/i18n/locales/pt/settings.json b/app/src/i18n/locales/pt/settings.json index 89dbb94ac..9d5a13bf2 100644 --- a/app/src/i18n/locales/pt/settings.json +++ b/app/src/i18n/locales/pt/settings.json @@ -23,7 +23,7 @@ "email": "E-mail", "role": "Função", "admin": "Administrador", - "contributor": "Contribuinte", + "user": "Contribuinte", "save-changes": "Salvar Alterações", "my-files-sub-title": "Aqui você pode encontrar todos os seus arquivos enviados, solicitações pendentes de harmonização e incluídos no Inventário de GEE", "all-inventory-years": "todos os anos de inventário", diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts index 396a699d4..72d38daba 100644 --- a/app/src/lib/auth.ts +++ b/app/src/lib/auth.ts @@ -6,11 +6,7 @@ import { CredentialInput, CredentialsConfig, } from "next-auth/providers/credentials"; - -export enum Roles { - User = "user", - Admin = "admin", -} +import { Roles } from "@/util/types"; // extracted from next-auth/providers/credentials // added here since the node test runner/ tsx wouldn't properly import ESM modules @@ -36,7 +32,7 @@ export default function Credentials< export type AppSession = DefaultSession & { user: { id: string; - role: string; + role: Roles; }; }; diff --git a/app/src/models/User.ts b/app/src/models/User.ts index a68c816b3..50595c08d 100644 --- a/app/src/models/User.ts +++ b/app/src/models/User.ts @@ -6,13 +6,15 @@ import type { Inventory, InventoryId } from "./Inventory"; import type { UserFile, UserFileId } from "./UserFile"; import { City, CityId } from "./City"; +import { Roles } from "@/util/types"; + export interface UserAttributes { userId: string; name?: string; pictureUrl?: string; email?: string; passwordHash?: string; - role?: string; + role?: Roles; created?: Date; lastUpdated?: Date; defaultInventoryId?: string; @@ -43,7 +45,7 @@ export class User pictureUrl?: string; email?: string; passwordHash?: string; - role?: string; + role?: Roles; created?: Date; lastUpdated?: Date; defaultInventoryId?: string; diff --git a/app/src/util/types.ts b/app/src/util/types.ts index 5d5e0c91d..c9a0d344c 100644 --- a/app/src/util/types.ts +++ b/app/src/util/types.ts @@ -186,6 +186,12 @@ export interface UserInviteResponse { lastUpdated: string; } +export enum Roles { + User = "user", + Admin = "admin", +} + + export interface AcceptInviteResponse { success: boolean; error?: string; diff --git a/app/tests/api/admin.jest.ts b/app/tests/api/admin.jest.ts index 102b12d8e..098a24919 100644 --- a/app/tests/api/admin.jest.ts +++ b/app/tests/api/admin.jest.ts @@ -10,14 +10,15 @@ import { jest, } from "@jest/globals"; import { mockRequest, setupTests, testUserData, testUserID } from "../helpers"; -import { AppSession, Auth, Roles } from "@/lib/auth"; +import { AppSession, Auth } from "@/lib/auth"; +import { Roles } from "@/util/types"; const mockSession: AppSession = { - user: { id: testUserID, role: "user" }, + user: { id: testUserID, role: Roles.User }, expires: "1h", }; const mockAdminSession: AppSession = { - user: { id: testUserID, role: "admin" }, + user: { id: testUserID, role: Roles.Admin }, expires: "1h", }; @@ -45,7 +46,10 @@ describe("Admin API", () => { }); it("should change the user role when logged in as admin", async () => { - const req = mockRequest({ email: testUserData.email, role: Roles.Admin }); + const req = mockRequest({ + email: testUserData.email, + role: Roles.Admin, + }); Auth.getServerSession = jest.fn(() => Promise.resolve(mockAdminSession)); const res = await changeRole(req, { params: {} }); expect(res.status).toBe(200); @@ -59,7 +63,10 @@ describe("Admin API", () => { }); it("should not change the user role when logged in as normal user", async () => { - const req = mockRequest({ email: testUserData.email, role: Roles.Admin }); + const req = mockRequest({ + email: testUserData.email, + role: Roles.Admin, + }); const res = await changeRole(req, { params: {} }); expect(res.status).toBe(403); diff --git a/app/tests/api/city.jest.ts b/app/tests/api/city.jest.ts index ab4a0b393..e5f9027f6 100644 --- a/app/tests/api/city.jest.ts +++ b/app/tests/api/city.jest.ts @@ -1,11 +1,10 @@ import { - jest, - expect, - describe, + afterAll, beforeAll, beforeEach, - afterAll, + describe, it, + jest, } from "@jest/globals"; import { DELETE as deleteCity, @@ -21,6 +20,7 @@ import { City } from "@/models/City"; import { randomUUID } from "node:crypto"; import { AppSession, Auth } from "@/lib/auth"; import { User } from "@/models/User"; +import { Roles } from "@/util/types"; const cityData: CreateCityRequest = { locode: "XX_CITY", @@ -47,7 +47,7 @@ const invalidCity = { }; const mockSession: AppSession = { - user: { id: testUserID, role: "user" }, + user: { id: testUserID, role: Roles.User }, expires: "1h", }; diff --git a/app/tests/api/datasource.jest.ts b/app/tests/api/datasource.jest.ts index 10977e749..56167f463 100644 --- a/app/tests/api/datasource.jest.ts +++ b/app/tests/api/datasource.jest.ts @@ -22,6 +22,7 @@ import { InventoryTypeEnum, } from "@/util/enums"; import { AppSession, Auth } from "@/lib/auth"; +import { Roles } from "@/util/types"; const locode = "XX_DATASOURCE_CITY"; const sectorName = "XX_DATASOURCE_TEST_1"; @@ -43,7 +44,7 @@ const sourceLocations = [ ]; const mockSession: AppSession = { - user: { id: testUserID, role: "user" }, + user: { id: testUserID, role: Roles.User }, expires: "1h", }; diff --git a/app/tests/helpers.ts b/app/tests/helpers.ts index 251088138..29b0b4aa3 100644 --- a/app/tests/helpers.ts +++ b/app/tests/helpers.ts @@ -15,6 +15,7 @@ import { DataSourceI18nAttributes } from "@/models/DataSourceI18n"; // TODO re-enable when migration to Jest is finished // import { expect } from "@jest/globals"; import assert from "node:assert"; +import { Roles } from "@/util/types"; function expect(received: any) { return { @@ -96,7 +97,7 @@ export const testUserData = { name: "Test User", email: "test@example.com", image: null, - role: "user", + role: Roles.User, }; export function setupTests() { From 94f0cf5753053688686a0abbd44fb329bc31671b Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Mon, 3 Feb 2025 09:30:34 -0300 Subject: [PATCH 3/7] feat: [ON-3245] [ON-3246] collaborators crud --- ...8154456-cityInvite__alter_type_user_id.cjs | 42 + app/package-lock.json | 170 +-- app/package.json | 2 + .../app/[lng]/[inventory]/settings/page.tsx | 21 +- .../app/api/v0/city/invite/[invite]/route.ts | 10 +- app/src/app/api/v0/user/[userId]/route.ts | 24 + .../v0/user/invites/[cityInviteId]/route.ts | 61 + .../app/api/v0/user/invites/accept/route.ts | 2 +- app/src/app/api/v0/user/invites/route.ts | 67 +- app/src/components/HomePage/ActionCards.tsx | 2 +- .../HomePage/AddCollaboratorButton.tsx | 8 +- .../AddCollaboratorsModal.tsx | 7 +- app/src/components/Modals/add-user-modal.tsx | 260 ----- .../components/Modals/delete-city-modal.tsx | 8 +- .../components/Modals/delete-user-modal.tsx | 53 +- .../components/Modals/update-user-modal.tsx | 89 +- .../MyProfileTab/AccountDetailsTabPanel.tsx | 125 ++ .../AddCollaboratorButtonSmall.tsx | 38 + .../MyProfileTab/ManageCitiesTabPanel.tsx | 230 ++++ .../Tabs/MyProfileTab/ManageUsersSubTable.tsx | 239 ++++ .../Tabs/MyProfileTab/ManageUsersTabPanel.tsx | 160 +++ .../Tabs/MyProfileTab/ManageUsersTable.tsx | 249 ++++ .../components/Tabs/MyProfileTab/index.tsx | 193 +++ .../components/Tabs/my-inventories-tab.tsx | 17 +- app/src/components/Tabs/my-profile-tab.tsx | 1034 ----------------- app/src/components/Texts/Button.tsx | 27 + app/src/components/Texts/Title.tsx | 13 + app/src/i18n/locales/de/settings.json | 11 +- app/src/i18n/locales/en/settings.json | 15 +- app/src/i18n/locales/es/settings.json | 13 +- app/src/i18n/locales/pt/settings.json | 13 +- app/src/lib/app-theme.ts | 1 + app/src/models/CityInvite.ts | 26 +- app/src/models/init-models.ts | 5 + app/src/services/api.ts | 42 +- app/src/util/types.ts | 23 + 36 files changed, 1761 insertions(+), 1539 deletions(-) create mode 100644 app/migrations/20250128154456-cityInvite__alter_type_user_id.cjs create mode 100644 app/src/app/api/v0/user/[userId]/route.ts create mode 100644 app/src/app/api/v0/user/invites/[cityInviteId]/route.ts delete mode 100644 app/src/components/Modals/add-user-modal.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/AccountDetailsTabPanel.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/AddCollaboratorButtonSmall.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/ManageCitiesTabPanel.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/ManageUsersSubTable.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/ManageUsersTabPanel.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/ManageUsersTable.tsx create mode 100644 app/src/components/Tabs/MyProfileTab/index.tsx delete mode 100644 app/src/components/Tabs/my-profile-tab.tsx create mode 100644 app/src/components/Texts/Button.tsx diff --git a/app/migrations/20250128154456-cityInvite__alter_type_user_id.cjs b/app/migrations/20250128154456-cityInvite__alter_type_user_id.cjs new file mode 100644 index 000000000..3638ade9a --- /dev/null +++ b/app/migrations/20250128154456-cityInvite__alter_type_user_id.cjs @@ -0,0 +1,42 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add a temporary column with UUID type + await queryInterface.addColumn("CityInvite", "user_id_temp", { + type: Sequelize.UUID, + allowNull: true, + }); + + // Copy data from user_id to user_id_temp + await queryInterface.sequelize.query( + 'UPDATE "CityInvite" SET "user_id_temp" = "user_id"::uuid', + ); + + // Remove the old user_id column + await queryInterface.removeColumn("CityInvite", "user_id"); + + // Rename the temporary column to user_id + await queryInterface.renameColumn("CityInvite", "user_id_temp", "user_id"); + }, + + async down(queryInterface, Sequelize) { + // Add a temporary column with STRING type + await queryInterface.addColumn("CityInvite", "user_id_temp", { + type: Sequelize.STRING, + allowNull: true, + }); + + // Copy data from user_id to user_id_temp + await queryInterface.sequelize.query( + 'UPDATE "CityInvite" SET "user_id_temp" = "user_id"::text', + ); + + // Remove the old user_id column + await queryInterface.removeColumn("CityInvite", "user_id"); + + // Rename the temporary column to user_id + await queryInterface.renameColumn("CityInvite", "user_id_temp", "user_id"); + }, +}; diff --git a/app/package-lock.json b/app/package-lock.json index 711357a3c..e6984cab2 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -81,6 +81,7 @@ "react-intersection-observer": "^9.8.2", "react-markdown": "^9.0.1", "react-redux": "^9.1.2", + "react-table": "^7.8.0", "redux-persist": "^6.0.0", "remark-gfm": "^4.0.0", "sequelize": "^6.37.3", @@ -106,6 +107,7 @@ "@types/glob": "^8.1.0", "@types/lodash.groupby": "^4.6.9", "@types/lodash.sumby": "^4.6.9", + "@types/react-table": "^7.7.20", "cypress": "^13.6.4", "glob": "^11.0.0", "jest": "^29.7.0", @@ -2341,9 +2343,9 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", - "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", + "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3712,15 +3714,15 @@ } }, "node_modules/@emotion/babel-plugin": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", - "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/serialize": "^1.1.2", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", @@ -3730,48 +3732,48 @@ } }, "node_modules/@emotion/cache": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", - "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", - "dependencies": { - "@emotion/memoize": "^0.8.1", - "@emotion/sheet": "^1.2.2", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "node_modules/@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", "peer": true, "dependencies": { - "@emotion/memoize": "^0.8.1" + "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" }, "node_modules/@emotion/react": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", - "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { @@ -3784,34 +3786,34 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", - "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "dependencies": { - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/unitless": "^0.8.1", - "@emotion/utils": "^1.2.1", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" }, "node_modules/@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", @@ -3824,27 +3826,27 @@ } }, "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", @@ -9182,9 +9184,9 @@ } }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/qs": { "version": "6.9.8", @@ -9213,15 +9215,23 @@ "@types/react": "*" } }, - "node_modules/@types/react-transition-group": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", - "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", - "license": "MIT", + "node_modules/@types/react-table": { + "version": "7.7.20", + "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.20.tgz", + "integrity": "sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==", + "dev": true, "dependencies": { "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", @@ -24609,6 +24619,18 @@ } } }, + "node_modules/react-table": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz", + "integrity": "sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/app/package.json b/app/package.json index 54e6157de..3092fb947 100644 --- a/app/package.json +++ b/app/package.json @@ -107,6 +107,7 @@ "react-intersection-observer": "^9.8.2", "react-markdown": "^9.0.1", "react-redux": "^9.1.2", + "react-table": "^7.8.0", "redux-persist": "^6.0.0", "remark-gfm": "^4.0.0", "sequelize": "^6.37.3", @@ -132,6 +133,7 @@ "@types/glob": "^8.1.0", "@types/lodash.groupby": "^4.6.9", "@types/lodash.sumby": "^4.6.9", + "@types/react-table": "^7.7.20", "cypress": "^13.6.4", "glob": "^11.0.0", "jest": "^29.7.0", diff --git a/app/src/app/[lng]/[inventory]/settings/page.tsx b/app/src/app/[lng]/[inventory]/settings/page.tsx index 86394b79c..aeaef8874 100644 --- a/app/src/app/[lng]/[inventory]/settings/page.tsx +++ b/app/src/app/[lng]/[inventory]/settings/page.tsx @@ -2,12 +2,11 @@ import React, { useState } from "react"; import { useTranslation } from "@/i18n/client"; -import { NavigationBar } from "@/components/navigation-bar"; import { Box, Tab, TabList, TabPanels, Tabs, Text } from "@chakra-ui/react"; import { useSession } from "next-auth/react"; -import MyProfileTab from "@/components/Tabs/my-profile-tab"; +import { MyProfileTab } from "@/components/Tabs/MyProfileTab"; import MyFilesTab from "@/components/Tabs/my-files-tab"; import MyInventoriesTab from "@/components/Tabs/my-inventories-tab"; import { api } from "@/services/api"; @@ -67,11 +66,6 @@ export default function Settings({ const cityId = inventory?.city.cityId; - const { data: cityUsers } = api.useGetCityUsersQuery( - { cityId: cityId! }, - { skip: !cityId }, - ); - const { data: userFiles } = api.useGetUserFilesQuery(cityId!, { skip: !cityId, }); @@ -135,16 +129,7 @@ export default function Settings({ - + { const invite = await db.models.CityInvite.findOne({ @@ -40,8 +40,10 @@ export const GET = apiHandler(async (req, { params, session }) => { const city = await db.models.City.findOne({ where: { cityId: invite.cityId }, }); - - await user?.addCity(city?.cityId); - + if (city && user) { + await user.addCity(city.cityId); + } else { + throw new createHttpError.NotFound("City or User not found"); + } return NextResponse.redirect(`${host}/${inventory}`); }); diff --git a/app/src/app/api/v0/user/[userId]/route.ts b/app/src/app/api/v0/user/[userId]/route.ts new file mode 100644 index 000000000..80da64a34 --- /dev/null +++ b/app/src/app/api/v0/user/[userId]/route.ts @@ -0,0 +1,24 @@ +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { Roles } from "@/util/types"; + +const updateUserRequest = z.object({ + name: z.string(), + role: z.nativeEnum(Roles), +}); + +export const PATCH = apiHandler(async (_req, { params, session }) => { + const body = updateUserRequest.parse(await _req.json()); + let user = await db.models.User.findOne({ where: { userId: params.userId } }); + + if (!user) { + throw new createHttpError.NotFound("User not found"); + } + + user = await user.update(body); + + return NextResponse.json({ data: user }); +}); diff --git a/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts b/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts new file mode 100644 index 000000000..7fc11d656 --- /dev/null +++ b/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts @@ -0,0 +1,61 @@ +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; +import { CityInviteStatus } from "@/util/types"; +import { CityUser } from "@/models/CityUser"; + +export const DELETE = apiHandler(async (req, { params, session }) => { + if (!session) { + throw new createHttpError.Unauthorized("Unauthorized"); + } + + const { cityInviteId } = params; + const invite = await db.models.CityInvite.findOne({ + where: { + id: cityInviteId, + invitingUserId: session.user.id, + }, + }); + if (!invite) { + console.error( + "error in invites/[cityInviteId]/route DELETE: ", + "CityInvite not found", + cityInviteId, + ); + throw createHttpError.Unauthorized("Unauthorized"); + } + await invite.update({ status: CityInviteStatus.CANCELED }); + const cityUser = await CityUser.findOne({ + where: { cityId: invite.cityId, userId: invite.userId }, + }); + if (cityUser) { + await cityUser.destroy(); + } + return NextResponse.json({ success: true }); +}); + +export const PATCH = apiHandler(async (req, { params, session }) => { + if (!session) { + throw new createHttpError.Unauthorized("Unauthorized"); + } + + const { cityInviteId } = params; + const invite = await db.models.CityInvite.findOne({ + where: { + id: cityInviteId, + invitingUserId: session.user.id, + }, + }); + + if (!invite) { + console.error( + "error in invites/[cityInviteId]/route DELETE: ", + "CityInvite not found", + cityInviteId, + ); + throw createHttpError.Unauthorized("Unauthorized"); + } + await invite.update({ status: CityInviteStatus.PENDING }); + return NextResponse.json({ success: true }); +}); diff --git a/app/src/app/api/v0/user/invites/accept/route.ts b/app/src/app/api/v0/user/invites/accept/route.ts index 07623cf59..ec1616901 100644 --- a/app/src/app/api/v0/user/invites/accept/route.ts +++ b/app/src/app/api/v0/user/invites/accept/route.ts @@ -6,7 +6,7 @@ import createHttpError from "http-errors"; import jwt, { JwtPayload } from "jsonwebtoken"; import { Op } from "sequelize"; import { logger } from "@/services/logger"; -import { CityInviteStatus } from "@/models/CityInvite"; +import { CityInviteStatus } from "@/util/types"; import { NextResponse } from "next/server"; export const PATCH = apiHandler(async (req, { params, session }) => { diff --git a/app/src/app/api/v0/user/invites/route.ts b/app/src/app/api/v0/user/invites/route.ts index 94f369c40..ab0f7fce8 100644 --- a/app/src/app/api/v0/user/invites/route.ts +++ b/app/src/app/api/v0/user/invites/route.ts @@ -10,6 +10,34 @@ import { render } from "@react-email/components"; import { InviteUserToMultipleCitiesTemplate } from "@/lib/emails/InviteUserToMultipleCitiesTemplate"; import { Op } from "sequelize"; import { logger } from "@/services/logger"; +import { CityInviteStatus } from "@/util/types"; + +export const GET = apiHandler(async (req, { params, session }) => { + if (!session) { + throw new createHttpError.Unauthorized("Not signed in"); + } + const invites = await db.models.CityInvite.findAll({ + where: { + invitingUserId: session?.user.id, + status: { [Op.ne]: CityInviteStatus.CANCELED }, + }, + include: [ + { + model: db.models.City, + as: "cityInvites", + required: true, + }, + { + model: db.models.User, + as: "user", + required: false, + attributes: ["userId", "name", "email", "role"], + }, + ], + }); + + return NextResponse.json({ data: invites }); +}); export const POST = apiHandler(async (req, { params, session }) => { if (!session) { @@ -49,22 +77,35 @@ export const POST = apiHandler(async (req, { params, session }) => { ); const invites = await Promise.all( cityIds.map(async (cityId) => { - const invite = await db.models.CityInvite.create({ - id: randomUUID(), - cityId, - email, - invitingUserId: session.user.id, + const existingInvite = await db.models.CityInvite.findOne({ + where: { email, cityId }, }); - if (!invite) { - failedInvites.push({ email, cityIds: [cityId] }); - logger.error( - "error in invites/route POST: ", - "error creating invite", - { cityId, email }, - ); + if (existingInvite) { + if (existingInvite.status !== CityInviteStatus.ACCEPTED) { + await existingInvite.update({ + status: CityInviteStatus.PENDING, + }); + } + return existingInvite; + } else { + const invite = await db.models.CityInvite.create({ + id: randomUUID(), + cityId, + email, + invitingUserId: session.user.id, + }); + + if (!invite) { + failedInvites.push({ email, cityIds: [cityId] }); + logger.error( + "error in invites/route POST: ", + "error creating invite", + { cityId, email }, + ); + } + return invite; } - return invite; }), ); const host = process.env.HOST ?? "http://localhost:3000"; diff --git a/app/src/components/HomePage/ActionCards.tsx b/app/src/components/HomePage/ActionCards.tsx index 6e533ebdf..277046b18 100644 --- a/app/src/components/HomePage/ActionCards.tsx +++ b/app/src/components/HomePage/ActionCards.tsx @@ -73,7 +73,7 @@ export function ActionCards({ - + void; onOpen: () => void; }) => { + const { t } = useTranslation(lng, "dashboard"); + const { showSuccessToast } = UseSuccessToast({ title: t("invite-success-toast-title"), description: t("invite-success-toast-description"), diff --git a/app/src/components/Modals/add-user-modal.tsx b/app/src/components/Modals/add-user-modal.tsx deleted file mode 100644 index a0c3d4681..000000000 --- a/app/src/components/Modals/add-user-modal.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; - -import { ProfileInputs } from "@/app/[lng]/[inventory]/settings/page"; -import type { UserAttributes } from "@/models/User"; -import { api } from "@/services/api"; -import { - Box, - Button, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Text, - useToast, -} from "@chakra-ui/react"; -import { FC, useState } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { MdCheckCircleOutline } from "react-icons/md"; -import FormInput from "../form-input"; -import FormSelectInput from "../form-select-input"; -import FormSelectOrganization from "../form-select-organization"; -import { TFunction } from "i18next"; -import { useParams } from "next/navigation"; - -interface AddUserModalProps { - isOpen: boolean; - onClose: () => void; - t: TFunction; - userInfo: UserAttributes | null; - defaultCityId?: string; -} - -const AddUserModal: FC = ({ - isOpen, - onClose, - t, - userInfo, - defaultCityId, -}) => { - const { - handleSubmit, - register, - formState: { errors }, - } = useForm(); - const [checkUser] = api.useCheckUserMutation(); - const [inviteUser, { isLoading: isInviteLoading }] = - api.useInviteUserMutation(); - - const toast = useToast(); - - const { inventory: cityParam } = useParams(); - const inventoryId = cityParam as string; - const onSubmit: SubmitHandler<{ name: string; email: string }> = async ( - data, - ) => { - await checkUser({ - email: data.email!, - cityId: defaultCityId!, - }).then(async (res: any) => { - if (res.error) { - return toast({ - description: t("something-went-wrong"), - status: "error", - duration: 5000, - isClosable: true, - render: () => ( - - - - - - {t("something-went-wrong")} - - - - ), - }); - } else { - await inviteUser({ - name: res.data ? res.data.name! : data.name, - cityId: defaultCityId!, - email: data.email!, - userId: res?.data?.userId, - invitingUserId: userInfo! && userInfo?.userId!, - inventoryId, - }).then((res: any) => { - onClose(); - if (res?.error?.status == 400) { - return toast({ - description: "Something went wrong", - status: "error", - duration: 5000, - isClosable: true, - render: () => ( - - - - - Something went wrong! - - - - ), - }); - } else { - return toast({ - description: "User invite sent", - status: "success", - duration: 5000, - isClosable: true, - render: () => ( - - - - - User invite sent - - - - ), - }); - } - }); - } - }); - }; - - return ( - <> - - - - - {t("add-user")} - - - -
- - - - {/* */} - -
-
- - - -
-
- - ); -}; - -export default AddUserModal; diff --git a/app/src/components/Modals/delete-city-modal.tsx b/app/src/components/Modals/delete-city-modal.tsx index 3d6cf3e82..f01161880 100644 --- a/app/src/components/Modals/delete-city-modal.tsx +++ b/app/src/components/Modals/delete-city-modal.tsx @@ -29,18 +29,14 @@ import { MdCheckCircleOutline } from "react-icons/md"; interface DeleteCityModalProps { isOpen: boolean; onClose: any; - userData: UserAttributes; cityData: CityAttributes; t: TFunction; - lng: string; } const DeleteCityModal: FC = ({ isOpen, onClose, - userData, cityData, - lng, t, }) => { const { @@ -51,9 +47,7 @@ const DeleteCityModal: FC = ({ } = useForm<{ password: string }>(); const [requestPasswordConfirm] = api.useRequestVerificationMutation(); - const { data: token } = api.useGetVerifcationTokenQuery({ - skip: !userData, - }); + const { data: token } = api.useGetVerifcationTokenQuery({}); const [removeCity] = api.useRemoveCityMutation(); const [isPasswordCorrect, setIsPasswordCorrect] = useState(true); const toast = useToast(); diff --git a/app/src/components/Modals/delete-user-modal.tsx b/app/src/components/Modals/delete-user-modal.tsx index 1307dbf91..cb7bbc0d1 100644 --- a/app/src/components/Modals/delete-user-modal.tsx +++ b/app/src/components/Modals/delete-user-modal.tsx @@ -1,45 +1,59 @@ "use client"; -import type { UserAttributes } from "@/models/User"; +import { UseErrorToast, UseSuccessToast } from "@/hooks/Toasts"; import { api } from "@/services/api"; import { - Modal, + Badge, + Box, Button, + Modal, ModalBody, ModalCloseButton, ModalContent, + ModalFooter, ModalHeader, ModalOverlay, Text, - Box, - Badge, - ModalFooter, } from "@chakra-ui/react"; import { TFunction } from "i18next"; import React, { FC } from "react"; -import { Trans } from "react-i18next"; import { FiTrash2 } from "react-icons/fi"; interface DeleteUserModalProps { isOpen: boolean; onClose: any; - userData: UserAttributes; - cityId: string; + cityInviteId: string; t: TFunction; } const DeleteUserModal: FC = ({ isOpen, onClose, - userData, - cityId, + cityInviteId, t, }) => { - const [removeUser] = api.useRemoveUserMutation(); - const handleDeleteUser = async (userId: string, cityId: string) => { - await removeUser({ userId, cityId }).then(() => { + const [cancelUserInvite, { isLoading, error }] = + api.useCancelInviteMutation(); + const { showSuccessToast } = UseSuccessToast({ + description: t("invite-canceled"), + title: t("invite-canceled"), + text: t("invite-canceled"), + }); + + const { showErrorToast } = UseErrorToast({ + description: t("invite-cancel-fail"), + title: t("invite-cancel-fail"), + text: t("invite-cancel-fail"), + }); + const handleCancelInvite = async () => { + await cancelUserInvite({ cityInviteId }).then(() => { onClose(); + if (error) { + showErrorToast(); + } else { + showSuccessToast(); + } }); }; return ( @@ -96,13 +110,9 @@ const DeleteUserModal: FC = ({ letterSpacing="wide" fontStyle="normal" > - - Are you sure you want to{" "} - - permanently remove this user - {" "} - from your team? - + {t("are-you-sure-you-want-to")}{" "} + {t("un-invite")}{" "} + {t("this-user-from-your-city")}
@@ -129,7 +139,8 @@ const DeleteUserModal: FC = ({ fontWeight="semibold" fontSize="button.md" type="submit" - onClick={() => handleDeleteUser(userData.userId, cityId)} + onClick={handleCancelInvite} + disabled={isLoading} p={0} m={0} > diff --git a/app/src/components/Modals/update-user-modal.tsx b/app/src/components/Modals/update-user-modal.tsx index c085b37c4..7c29d2031 100644 --- a/app/src/components/Modals/update-user-modal.tsx +++ b/app/src/components/Modals/update-user-modal.tsx @@ -1,103 +1,79 @@ "use client"; import { - Modal, + Box, Button, + Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, - Text, - ModalProps, - Input, - FormControl, - FormLabel, - Box, - useToast, } from "@chakra-ui/react"; import React, { FC, useEffect, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import FormInput from "../form-input"; import FormSelectInput from "../form-select-input"; -import { UserAttributes } from "@/models/User"; -import { MdCheckCircleOutline } from "react-icons/md"; import { api } from "@/services/api"; import { TFunction } from "i18next"; +import { GetUserCityInvitesResponseUserData, Roles } from "@/util/types"; +import { UseErrorToast, UseSuccessToast } from "@/hooks/Toasts"; interface UpdateUserModalProps { isOpen: boolean; onClose: any; - userData: UserAttributes; - userInfo: UserAttributes; t: TFunction; + userData: GetUserCityInvitesResponseUserData; } const UpdateUserModal: FC = ({ isOpen, onClose, - userData, - userInfo, t, + userData, }) => { const { handleSubmit, register, formState: { errors, isSubmitting }, setValue, - } = useForm(); + } = useForm(); - const [setUserData] = api.useSetUserDataMutation(); + const [setUserData, { isLoading, error }] = api.useSetUserDataMutation(); - const toast = useToast(); + const { showSuccessToast } = UseSuccessToast({ + description: t("user-details-updated"), + title: t("user-details-updated"), + text: t("user-details-updated"), + }); + + const { showErrorToast } = UseErrorToast({ + description: t("user-details-update-fail"), + title: t("user-details-update-fail"), + text: t("user-details-update-fail"), + }); const [inputValue, setInputValue] = useState(""); - const onSubmit: SubmitHandler = async (data) => { - // TODO + const onSubmit: SubmitHandler = async ({ + role, + email, + name, + }) => { // Submit data via the api await setUserData({ - cityId: "", // TODO pass currently selected city's ID in here! userId: userData.userId, - name: data.name, - email: data.email, - role: data.role, + name: name, + email: email, + role: role === "admin" ? Roles.Admin : Roles.User, }).then(() => { onClose(); - toast({ - description: t("user-details-updated"), - status: "success", - duration: 5000, - isClosable: true, - render: () => ( - - - - - - {t("user-details-updated")} - - - - ), - }); + if (error) { + showErrorToast(); + } else { + showSuccessToast(); + } }); }; @@ -175,6 +151,7 @@ const UpdateUserModal: FC = ({ fontWeight="semibold" fontSize="button.md" type="submit" + disabled={isLoading} onClick={handleSubmit(onSubmit)} p={0} m={0} diff --git a/app/src/components/Tabs/MyProfileTab/AccountDetailsTabPanel.tsx b/app/src/components/Tabs/MyProfileTab/AccountDetailsTabPanel.tsx new file mode 100644 index 000000000..74b286fb0 --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/AccountDetailsTabPanel.tsx @@ -0,0 +1,125 @@ +import { FC, useState } from "react"; +import { Box, Button, Text, useToast } from "@chakra-ui/react"; +import { useForm, SubmitHandler } from "react-hook-form"; +import { MdCheckCircleOutline } from "react-icons/md"; +import { ProfileInputs } from "@/app/[lng]/[inventory]/settings/page"; +import FormInput from "../../form-input"; +import EmailInput from "../../email-input"; +import FormSelectInput from "../../form-select-input"; +import { useSetCurrentUserDataMutation } from "@/services/api"; +import { TFunction } from "i18next"; + +interface AccountDetailsFormProps { + t: TFunction; + userInfo: any; +} + +const AccountDetailsTabPanel: FC = ({ + t, + userInfo, +}) => { + const [inputValue, setInputValue] = useState(""); + const { + handleSubmit, + register, + formState: { errors, isSubmitting }, + } = useForm(); + const [setCurrentUserData] = useSetCurrentUserDataMutation(); + const toast = useToast(); + + const onSubmit: SubmitHandler = async (data) => { + await setCurrentUserData({ + userId: userInfo.userId, + name: data.name, + email: data.email, + role: data.role, + }).then(() => + toast({ + description: t("user-details-updated"), + status: "success", + duration: 5000, + isClosable: true, + render: () => ( + + + + + {t("user-details-updated")} + + + + ), + }), + ); + }; + + const onInputChange = (e: any) => { + setInputValue(e.target.value); + }; + + return ( + +
+ + + + + + + +
+ ); +}; + +export default AccountDetailsTabPanel; diff --git a/app/src/components/Tabs/MyProfileTab/AddCollaboratorButtonSmall.tsx b/app/src/components/Tabs/MyProfileTab/AddCollaboratorButtonSmall.tsx new file mode 100644 index 000000000..5af3b6425 --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/AddCollaboratorButtonSmall.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { MdPersonAdd } from "react-icons/md"; +import { Button, HStack, IconButton, useDisclosure } from "@chakra-ui/react"; +import type { TFunction } from "i18next"; +import AddCollaboratorsModal from "@/components/HomePage/AddCollaboratorModal/AddCollaboratorsModal"; +import { ButtonMedium } from "@/components/Texts/Button"; +import { useTranslation } from "@/i18n/client"; + +export function AddCollaboratorButtonSmall({ lng }: { lng: string }) { + const { t } = useTranslation(lng, "settings"); + const { + isOpen: isModalOpen, + onOpen: onModalOpen, + onClose: onModalClose, + } = useDisclosure(); + + return ( + <> + + + + ); +} diff --git a/app/src/components/Tabs/MyProfileTab/ManageCitiesTabPanel.tsx b/app/src/components/Tabs/MyProfileTab/ManageCitiesTabPanel.tsx new file mode 100644 index 000000000..ec3027b9a --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/ManageCitiesTabPanel.tsx @@ -0,0 +1,230 @@ +import { FC, useState } from "react"; +import { + Box, + Button, + IconButton, + List, + ListItem, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useDisclosure, +} from "@chakra-ui/react"; +import { AddIcon } from "@chakra-ui/icons"; +import { FiTrash2 } from "react-icons/fi"; +import { MdDomain, MdMoreVert, MdOutlineFileDownload } from "react-icons/md"; +import NextLink from "next/link"; +import { TFunction } from "i18next"; +import { api } from "@/services/api"; +import DeleteCityModal from "@/components/Modals/delete-city-modal"; +import { CityAttributes } from "@/models/City"; + +interface ManageCitiesProps { + t: TFunction; +} + +const ManageCitiesTabPanel: FC = ({ t }) => { + const { data: citiesAndYears, isLoading: isCitiesLoading } = + api.useGetCitiesAndYearsQuery(); + + const { + isOpen: isCityDeleteModalOpen, + onOpen: onCityDeleteModalOpen, + onClose: onCityDeleteModalClose, + } = useDisclosure(); + const [cityData, setCityData] = useState(); + + return ( + <> + + + {t("city")} + + + + + + + + + + + + + + + + + + {citiesAndYears?.map(({ city }) => ( + + + + + + + ))} + +
{t("city-name")}{t("state-province")}{t("country")}{t("last-updated")}
+ + + {city.name} + + {city.region}{city.country} + + {city?.lastUpdated && + new Date(city.lastUpdated).toLocaleDateString()} + + + + } + /> + + + + + + + + + {t("download-city-data")} + + + { + setCityData(city); + onCityDeleteModalOpen(); + }} + > + + + {t("remove-city")} + + + + + + +
+
+
+ {!!cityData && ( + + )} + + ); +}; + +export default ManageCitiesTabPanel; diff --git a/app/src/components/Tabs/MyProfileTab/ManageUsersSubTable.tsx b/app/src/components/Tabs/MyProfileTab/ManageUsersSubTable.tsx new file mode 100644 index 000000000..5297e92e2 --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/ManageUsersSubTable.tsx @@ -0,0 +1,239 @@ +import React, { useMemo, useState } from "react"; +import { ButtonSmall } from "@/components/Texts/Button"; +import { Column, Row, useTable } from "react-table"; +import { CityInviteStatus, GetUserCityInvitesResponse } from "@/util/types"; +import { MdOutlineDelete, MdOutlineReplay } from "react-icons/md"; +import { ChevronDownIcon } from "@chakra-ui/icons"; +import { Badge, IconButton } from "@chakra-ui/react"; +import DeleteUserModal from "@/components/Modals/delete-user-modal"; +import type { TFunction } from "i18next"; +import { api } from "@/services/api"; +import { UseErrorToast, UseSuccessToast } from "@/hooks/Toasts"; + +const ManageUsersSubTable = React.memo(function SubTable({ + invites, + theme, + t, +}: { + invites: GetUserCityInvitesResponse[]; + theme: any; + t: TFunction; +}) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedRowId, setSelectedRowId] = useState(null); + const [resetUserInvite, { isLoading, error }] = api.useResetInviteMutation(); + + const { showSuccessToast } = UseSuccessToast({ + description: t("invite-sent"), + title: t("invite-sent"), + text: t("invite-sent"), + }); + + const { showErrorToast } = UseErrorToast({ + description: t("invite-send-fail"), + title: t("invite-send-fail"), + text: t("invite-send-fail"), + }); + + const handleDeleteClick = (row: Row) => { + setSelectedRowId(row.original.id); + setIsModalOpen(true); + }; + + const handleResetClick = (row: Row) => { + setSelectedRowId(row.original.id); + resetUserInvite({ cityInviteId: row.original.id }); + if (error) { + showErrorToast(); + } else { + showSuccessToast(); + } + }; + + const subTableColumns: Column[] = useMemo(() => { + const getTextAndBorderColor = (value: CityInviteStatus) => { + switch (value) { + case CityInviteStatus.ACCEPTED: + return theme.colors.sentiment.positiveDefault; + case CityInviteStatus.PENDING: + return theme.colors.sentiment.warningDefault; + default: + return theme.colors.interactive.control; + } + }; + + const getBackgroundColor = (value: CityInviteStatus) => { + switch (value) { + case CityInviteStatus.ACCEPTED: + return theme.colors.sentiment.positiveOverlay; + case CityInviteStatus.PENDING: + return theme.colors.sentiment.warningOverlay; + default: + return theme.colors.background.neutral; + } + }; + return [ + { + Header: () => ( + + ), + id: "spacer", + accessor: () => {}, + }, + { + Header: () => ( + + City Name + + ), + accessor: (row) => row.cityInvites.name, + width: "33.33%", + id: "cityName", + }, + { + Header: () => ( + + Status + + ), + accessor: "status", + width: "33.33%", + id: "status", + Cell: ({ value }) => ( + + {value} + + ), + }, + { + Header: "", + id: "spacer2", + width: "33.33%", + }, + { + Header: "", + id: "actions", + width: "40px", + Cell: ({ row }) => + row.original.status === CityInviteStatus.EXPIRED ? ( + handleResetClick(row)} + icon={ + + } + aria-label="edit" + variant="ghost" + color="content.tertiary" + /> + ) : ( + handleDeleteClick(row)} + icon={ + + } + aria-label="edit" + variant="ghost" + color="content.tertiary" + /> + ), + }, + ]; + }, [theme]); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable({ + columns: subTableColumns, + data: invites, + }); + + return ( + <> + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} + + ))} + + + {rows.map((subRow) => { + prepareRow(subRow); + return ( + + {subRow.cells.map((cell) => ( + + ))} + + ); + })} + +
+ {column.render("Header")} +
+ {cell.render("Cell")} +
+ {selectedRowId && ( + setIsModalOpen(false)} + cityInviteId={selectedRowId} + t={t} + /> + )} + + ); +}); + +export default ManageUsersSubTable; diff --git a/app/src/components/Tabs/MyProfileTab/ManageUsersTabPanel.tsx b/app/src/components/Tabs/MyProfileTab/ManageUsersTabPanel.tsx new file mode 100644 index 000000000..d11970e13 --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/ManageUsersTabPanel.tsx @@ -0,0 +1,160 @@ +import React, { FC, useEffect, useState } from "react"; + +import { + Box, + Button, + Center, + CircularProgress, + HStack, + Input, + InputGroup, + InputLeftElement, + Select, + Text, +} from "@chakra-ui/react"; +import { api } from "@/services/api"; +import { TFunction } from "i18next"; +import { GetUserCityInvitesResponse } from "@/util/types"; +import ManageUsersTable from "./ManageUsersTable"; +import { + ChevronLeftIcon, + ChevronRightIcon, + SearchIcon, +} from "@chakra-ui/icons"; +import { TitleMedium } from "@/components/Texts/Title"; +import { AddCollaboratorButtonSmall } from "./AddCollaboratorButtonSmall"; +import { useTranslation } from "@/i18n/client"; + +interface ManageUsersProps { + lng: string; +} + +const ManageUsersTabPanel: FC = ({ lng }) => { + const { t } = useTranslation(lng, "settings"); + const { data: cityInvites, isLoading: isCityInvitesLoading } = + api.useGetCityInvitesQuery(); + const [filterTerm, setFilterTerm] = useState(""); + const [filterRole, setFilterRole] = useState("all"); + + const [filteredInvites, setFilteredInvites] = useState< + Array + >([]); + + useEffect(() => { + if (cityInvites) { + const result = cityInvites.filter((invite: any) => { + const matchesSearchTerm = + !filterTerm || + invite.user?.name + .toLocaleLowerCase() + .includes(filterTerm.toLocaleLowerCase()) || + invite.user?.email + .toLocaleLowerCase() + .includes(filterTerm.toLocaleLowerCase()); + const matchesRole = + filterRole === "all" || + invite.user?.role.toLocaleLowerCase() === + filterRole.toLocaleLowerCase(); + return matchesSearchTerm && matchesRole; + }); + setFilteredInvites(result); + } else { + setFilteredInvites([]); + } + }, [filterRole, filterTerm, cityInvites]); + + return ( + <> + + {t("manage-users")} + + + {cityInvites ? ( + <> + + + + + + + setFilterTerm(e.target.value)} + /> + + + + + + 1-{filteredInvites.length} of {filteredInvites.length} + + + + + + + + + + ) : ( +
+ +
+ )} + + ); +}; + +export default ManageUsersTabPanel; diff --git a/app/src/components/Tabs/MyProfileTab/ManageUsersTable.tsx b/app/src/components/Tabs/MyProfileTab/ManageUsersTable.tsx new file mode 100644 index 000000000..a9e5a0949 --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/ManageUsersTable.tsx @@ -0,0 +1,249 @@ +import React, { useMemo, useState } from "react"; +import { ButtonSmall } from "@/components/Texts/Button"; +import { + CellProps, + Column, + Row, + TableInstance, + useExpanded, + useTable, +} from "react-table"; +import { + GetUserCityInvitesResponse, + GetUserCityInvitesResponseUserData, +} from "@/util/types"; +import { MdOutlineMode } from "react-icons/md"; +import { ChevronDownIcon, ChevronRightIcon } from "@chakra-ui/icons"; +import { IconButton, useTheme } from "@chakra-ui/react"; +import ManageUsersSubTable from "./ManageUsersSubTable"; +import type { TFunction } from "i18next"; +import UpdateUserModal from "@/components/Modals/update-user-modal"; + +interface GroupedInvites { + name: string; + email: string; + invites: GetUserCityInvitesResponse[]; +} + +interface ExtendedRow extends Row { + isExpanded: boolean; + getToggleRowExpandedProps: () => any; +} + +const ManageUsersTable = ({ + cityInvites, + t, +}: { + cityInvites: GetUserCityInvitesResponse[]; + t: TFunction; +}) => { + const theme = useTheme(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedUser, setSelectedUser] = + useState(null); + const handleEditClick = ( + cell: Row<{ + name: string; + email: string; + invites: GetUserCityInvitesResponse[]; + }>, + ) => { + const user = cell.original.invites[0].user; + if (user) { + setSelectedUser(user); + setIsModalOpen(true); + } + }; + const data = useMemo(() => { + const grouped = cityInvites.reduce( + (acc, invite) => { + const { email, id } = invite; + if (!acc[email]) { + acc[email] = { + id, + name: invite.user?.name || email, + email, + invites: [], + }; + } + acc[email].invites.push(invite); + return acc; + }, + {} as Record< + string, + { + id: string; + name: string; + email: string; + invites: GetUserCityInvitesResponse[]; + } + >, + ); + + return Object.values(grouped); + }, [cityInvites]); + + const columns: Column<{ + name: string; + email: string; + invites: GetUserCityInvitesResponse[]; + }>[] = useMemo( + () => [ + { + Header: "", + id: "expander", + width: "40px", + Cell: ({ + row, + }: CellProps<{ + name: string; + email: string; + invites: GetUserCityInvitesResponse[]; + }>) => ( + + ).getToggleRowExpandedProps()} + > + {(row as ExtendedRow).isExpanded ? ( + + ) : ( + + )} + + ), + }, + { + Header: () => ( + + {t("name")} + + ), + accessor: "name", + width: "33.33%", + id: "name", + }, + { + Header: () => ( + + {t("Email")} + + ), + accessor: "email", + width: "33.33%", + id: "email", + }, + { + id: "cityCount", + Header: () => ( + + {t("number-of-cities")} + + ), + accessor: (row) => row.invites.length, + width: "33.33%", + }, + { + Header: "", + id: "edit", + width: "40px", + Cell: ({ row }) => ( + handleEditClick(row)} + icon={ + + } + aria-label="edit" + variant="ghost" + color="content.tertiary" + /> + ), + }, + ], + [theme], + ); + + const renderRowSubComponent = React.useCallback( + ({ row }: { row: ExtendedRow }) => ( + + ), + [theme], + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable({ columns, data }, useExpanded) as TableInstance; + + return ( + <> + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} + + ))} + + + {rows.map((row) => { + prepareRow(row); + return ( + + + {row.cells.map((cell) => ( + + ))} + + {(row as ExtendedRow).isExpanded ? ( + + + + ) : null} + + ); + })} + +
+ {column.render("Header")} +
+ {cell.render("Cell")} +
+ {renderRowSubComponent({ + row: row as ExtendedRow, + })} +
+ {selectedUser && ( + setIsModalOpen(false)} + userData={selectedUser} + t={t} + /> + )} + + ); +}; + +export default ManageUsersTable; diff --git a/app/src/components/Tabs/MyProfileTab/index.tsx b/app/src/components/Tabs/MyProfileTab/index.tsx new file mode 100644 index 000000000..20ed6eb00 --- /dev/null +++ b/app/src/components/Tabs/MyProfileTab/index.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { + Box, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, +} from "@chakra-ui/react"; +import { FC } from "react"; +import { UserAttributes } from "@/models/User"; +import { TFunction } from "i18next"; +import AccountDetailsTabPanel from "./AccountDetailsTabPanel"; +import ManageUsersTabPanel from "./ManageUsersTabPanel"; +import ManageCitiesTabPanel from "./ManageCitiesTabPanel"; + +interface MyProfileTabProps { + t: TFunction; + userInfo: UserAttributes | any; + lng: string; +} + +export const MyProfileTab: FC = ({ t, lng, userInfo }) => { + return ( + + + + + {t("my-profile")} + + + {t("my-profile-sub-title")} + + + + + + + {t("account-details")} + + + {t("users")} + + + {t("city")} + + + + + + + + {t("account-details")} + + + {t("my-profile-sub-title")} + + + + + + + + + + + + + + + + ); +}; diff --git a/app/src/components/Tabs/my-inventories-tab.tsx b/app/src/components/Tabs/my-inventories-tab.tsx index 19072e995..cf8833118 100644 --- a/app/src/components/Tabs/my-inventories-tab.tsx +++ b/app/src/components/Tabs/my-inventories-tab.tsx @@ -1,7 +1,6 @@ "use client"; import { - Avatar, Box, IconButton, List, @@ -13,11 +12,11 @@ import { PopoverTrigger, Progress, Tab, + Table, + TableContainer, TabList, TabPanel, TabPanels, - Table, - TableContainer, Tabs, Tbody, Td, @@ -36,8 +35,6 @@ import { } from "react-icons/md"; import { FiTrash2 } from "react-icons/fi"; -import type { Session } from "next-auth"; - import type { TFunction } from "i18next"; import DeleteInventoryModal from "../Modals/delete-inventory-modal"; import type { UserAttributes } from "@/models/User"; @@ -45,10 +42,9 @@ import type { CityAttributes } from "@/models/City"; import { api } from "@/services/api"; import type { InventoryAttributes } from "@/models/Inventory"; import { CircleFlag } from "react-circle-flags"; +import { Roles } from "@/util/types"; interface MyInventoriesTabProps { - session: Session | null; - status: "loading" | "authenticated" | "unauthenticated"; t: TFunction; lng: string; cities: CityAttributes[] | any; @@ -56,8 +52,6 @@ interface MyInventoriesTabProps { } const MyInventoriesTab: FC = ({ - session, - status, t, lng, cities, @@ -86,7 +80,7 @@ const MyInventoriesTab: FC = ({ email: "", userId: "", name: "", - role: "", + role: Roles.User, }); return ( @@ -244,7 +238,6 @@ const MyInventoriesTab: FC = ({ {inventory.year} - {/* TODO */} {/* generate status from progress API */} @@ -256,7 +249,7 @@ const MyInventoriesTab: FC = ({ width="137px" /> - + {/* TODO remove hardcoded date https://openearth.atlassian.net/browse/ON-3350 */} 21 Sept, 2023 diff --git a/app/src/components/Tabs/my-profile-tab.tsx b/app/src/components/Tabs/my-profile-tab.tsx deleted file mode 100644 index 5c6203476..000000000 --- a/app/src/components/Tabs/my-profile-tab.tsx +++ /dev/null @@ -1,1034 +0,0 @@ -"use client"; - -import { ProfileInputs } from "@/app/[lng]/[inventory]/settings/page"; -import AddUserModal from "@/components/Modals/add-user-modal"; -import DeleteUserModal from "@/components/Modals/delete-user-modal"; -import UpdateUserModal from "@/components/Modals/update-user-modal"; -import { - AddIcon, - ChevronLeftIcon, - ChevronRightIcon, - SearchIcon, -} from "@chakra-ui/icons"; -import { - Badge, - Box, - Button, - Checkbox, - IconButton, - Input, - InputGroup, - InputLeftElement, - List, - ListItem, - Popover, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, - Select, - Tab, - TabList, - TabPanel, - TabPanels, - Table, - TableContainer, - Tabs, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - useDisclosure, - useToast, -} from "@chakra-ui/react"; -import { Session } from "next-auth"; -import NextLink from "next/link"; -import { FC, useEffect, useState } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { FiTrash2 } from "react-icons/fi"; -import { - MdCheckCircleOutline, - MdDomain, - MdMoreVert, - MdOutlineFileDownload, - MdOutlineIndeterminateCheckBox, - MdOutlineModeEditOutline, -} from "react-icons/md"; -import FormInput from "../form-input"; -import FormSelectInput from "../form-select-input"; - -import DeleteCityModal from "@/components/Modals/delete-city-modal"; -import { CityAttributes } from "@/models/City"; -import { UserAttributes } from "@/models/User"; -import { api, useSetCurrentUserDataMutation } from "@/services/api"; -import { TFunction } from "i18next"; -import EmailInput from "../email-input"; - -interface MyProfileTabProps { - session: Session | null; - status: "loading" | "authenticated" | "unauthenticated"; - t: TFunction; - lng: string; - userInfo: UserAttributes | any; - cityUsers: UserAttributes[] | any; - cities: CityAttributes[] | any; - defaultCityId: string | undefined; -} - -const MyProfileTab: FC = ({ - session, - status, - t, - lng, - userInfo, - cityUsers, - cities, - defaultCityId, -}) => { - const [inputValue, setInputValue] = useState(""); - const { - handleSubmit, - register, - formState: { errors, isSubmitting }, - setValue, - } = useForm(); - - useEffect(() => { - if (userInfo) { - setValue("name", userInfo.name); - setValue("city", "City"); - setValue("email", userInfo.email!); - setValue("role", userInfo.role); - } - }, [setValue, session, status, userInfo]); - - const [setCurrentUserData] = useSetCurrentUserDataMutation(); - const toast = useToast(); - const onSubmit: SubmitHandler = async (data) => { - await setCurrentUserData({ - cityId: defaultCityId!, - userId: userInfo.userId, - name: data.name, - email: data.email, - role: data.role, - }).then(() => - toast({ - description: "User details updated!", - status: "success", - duration: 5000, - isClosable: true, - render: () => ( - - - - - - User details updated - - - - ), - }), - ); - }; - - const onInputChange = (e: any) => { - setInputValue(e.target.value); - }; - - const [selectedUsers, setSelectedUsers] = useState([]); - - const handleCheckboxChange = (userId: string, isChecked: boolean) => { - if (isChecked) { - setSelectedUsers((prev: any) => [...prev, userId]); - } else { - setSelectedUsers((prev: []) => - prev.filter((id: string) => id !== userId), - ); - } - }; - - const [searchTerm, setSearchTerm] = useState(""); - const [role, setRole] = useState(""); - const [filteredUsers, setFilteredUsers] = useState>([]); - const [filteredUsersByRole, setFilteredUsersByRole] = useState< - Array - >([]); - - useEffect(() => { - if (cityUsers) { - const result = cityUsers.filter( - (users: any) => - users.name - .toLocaleLowerCase() - .includes(searchTerm.toLocaleLowerCase()) || - users.email - .toLocaleLowerCase() - .includes(searchTerm.toLocaleLowerCase()), - ); - - setFilteredUsers(result); - } - }, [role, searchTerm, cityUsers]); - - useEffect(() => { - const selectedUsersByRole = filteredUsers.filter((users) => - users?.role?.toLocaleLowerCase().includes(role.toLocaleLowerCase()), - ); - if (role !== "all") { - setFilteredUsersByRole(selectedUsersByRole); - } else { - setFilteredUsersByRole(filteredUsers); - } - }, [filteredUsers, role]); - - const { - isOpen: isUserModalOpen, - onOpen: onUserModalOpen, - onClose: onUserModalClose, - } = useDisclosure(); - const { - isOpen: isUserUpdateModalOpen, - onOpen: onUserUpdateModalOpen, - onClose: onUserUpdateModalClose, - } = useDisclosure(); - - const { - isOpen: isUserDeleteModalOpen, - onOpen: onUserDeleteModalOpen, - onClose: onUserDeleteModalClose, - } = useDisclosure(); - - const { - isOpen: isCityDeleteModalOpen, - onOpen: onCityDeleteModalOpen, - onClose: onCityDeleteModalClose, - } = useDisclosure(); - - const [userData, setUserData] = useState({ - email: "", - userId: "", - name: "", - role: "", - }); - - const [cityData, setCityData] = useState({ - cityId: "", - name: "", - region: "", - country: "", - lastUpdated: undefined, - }); - - const [removeUser] = api.useRemoveUserMutation(); - const handleDeleteUsers = async () => { - selectedUsers.map(async (user: string) => { - await removeUser({ - userId: user, - cityId: defaultCityId!, - }).then((res: any) => { - if (res.data.deleted) { - toast({ - description: "User details updated!", - status: "success", - duration: 5000, - isClosable: true, - render: () => ( - - - - - - {t("users-deleted-from-city")} - - - - ), - }); - } - }); - }); - }; - - return ( - <> - - - - - {t("my-profile")} - - - {t("my-profile-sub-title")} - - - - - - - {t("account-details")} - - - {t("users")} - - - {t("city")} - - - - - - - - {t("account-details")} - - - {t("my-profile-sub-title")} - - - -
- - - - - - - - -
-
- - - - {t("manage-users")} - - - - - - - - - - setSearchTerm(e.target.value)} - /> - - - - - - 1-{filteredUsers.length} of {filteredUsers.length} - - - - - - - - {selectedUsers.length > 0 && ( - - - - - {selectedUsers.length} {t("selected-users")} - - - - - - - )} - - - - - - - - - - - - - {filteredUsersByRole.map((user) => ( - - - - - - - ))} - -
{t("select")}{t("name")}{t("email")}{t("role")}
- - handleCheckboxChange( - user.userId, - e.target.checked, - ) - } - isChecked={selectedUsers.includes( - user.userId, - )} - /> - {user.name}{user.email} - - {t(`${user.role}`)} - - - - - - - - - - - { - setUserData(user); - onUserUpdateModalOpen(); - }} - > - - - - {t("edit-user")} - - - { - setUserData(user); - onUserDeleteModalOpen(); - }} - > - - - - {t("remove-user")} - - - - - - -
-
-
-
- - - - {t("city")} - - - - - - - - - - - - - - - - - - {cities?.map((city: any) => ( - - - - - - - - ))} - -
{t("city-name")}{t("state-province")}{t("country")}{t("last-updated")}
- - - {city.name} - - {city.region}{city.country} - - {new Date( - city.last_updated, - ).toLocaleDateString()} - - - - } - > - - - - - - - - - - - {t("download-city-data")} - - - { - setCityData(city); - onCityDeleteModalOpen(); - }} - > - - - - {t("remove-city")} - - - - - - -
-
-
-
-
-
-
-
-
- - - - - - ); -}; - -export default MyProfileTab; diff --git a/app/src/components/Texts/Button.tsx b/app/src/components/Texts/Button.tsx new file mode 100644 index 000000000..9f8a9c92c --- /dev/null +++ b/app/src/components/Texts/Button.tsx @@ -0,0 +1,27 @@ +import { Text, TextProps } from "@chakra-ui/react"; + +export const ButtonSmall = (props: TextProps) => ( + + {props.children} + +); + +export const ButtonMedium = (props: TextProps) => ( + + {props.children} + +); diff --git a/app/src/components/Texts/Title.tsx b/app/src/components/Texts/Title.tsx index a3d9c65e0..e34b1b23a 100644 --- a/app/src/components/Texts/Title.tsx +++ b/app/src/components/Texts/Title.tsx @@ -16,3 +16,16 @@ export const TitleLarge = ({ text, ...props }: TitleProps) => ( {text} ); + +export const TitleMedium = ({ ...props }: HeadingProps) => ( + + {props.children} + +); diff --git a/app/src/i18n/locales/de/settings.json b/app/src/i18n/locales/de/settings.json index 29d51641d..d56492db7 100644 --- a/app/src/i18n/locales/de/settings.json +++ b/app/src/i18n/locales/de/settings.json @@ -62,5 +62,14 @@ "delete-file-prompt": "Sind Sie sicher, dass Sie diese Datei <2>dauerhaft löschen möchten aus dem Repository der Stadt?", "mark-as-completed": "Als abgeschlossen markieren", "password-required": "Password ist ein Pflichtfeld", - "min-length": "Minimallänge sollte {{length}} sein" + "min-length": "Minimallänge sollte {{length}} sein", + "are-you-sure-you-want-to": "Sind Sie sicher, dass Sie", + "un-invite": "die Einladung zurückziehen", + "this-user-from-your-city": "diesen Benutzer aus Ihrer Stadt entfernen möchten?", + "user-details-update-fail": "Benutzerdetails konnten nicht aktualisiert werden", + "invite-canceled": "Einladung storniert", + "invite-cancel-fail": "Einladung konnte nicht storniert werden", + "invite-sent": "Einladung erfolgreich gesendet", + "invite-send-fail": "Einladung konnte nicht gesendet werden", + "number-of-cities": "# der Städte" } diff --git a/app/src/i18n/locales/en/settings.json b/app/src/i18n/locales/en/settings.json index 3d29c4df0..6ec38723e 100644 --- a/app/src/i18n/locales/en/settings.json +++ b/app/src/i18n/locales/en/settings.json @@ -12,7 +12,7 @@ "enter-password-description": "Enter your password to confirm the deletion of the cities information", "incorrect-password": "Incorrect password! Please try again", "full-name": "Full Name", - "manage-users": "manage users", + "manage-users": "Manage users", "search-filter-placeholder": "Search by name or email address", "all": "All", "select": "Select", @@ -62,5 +62,14 @@ "delete-file-prompt": "Are you sure you want to <2> permanently delete this file from the city's repository?", "mark-as-completed": "Mark as completed", "password-required": "Password is required", - "min-length": "Minimum length should be {{length}}" -} + "min-length": "Minimum length should be {{length}}", + "are-you-sure-you-want-to": "Are you sure you want to", + "un-invite": "un-invite", + "this-user-from-your-city": "this user from your city?", + "user-details-update-fail": "User details couldn't be updated", + "invite-canceled": "Invite canceled", + "invite-cancel-fail": "Invite couldn't be canceled", + "invite-sent": "Invite sent successfully", + "invite-send-fail": "Invite couldn't be sent", + "number-of-cities": "# of cities" +} \ No newline at end of file diff --git a/app/src/i18n/locales/es/settings.json b/app/src/i18n/locales/es/settings.json index 62457e6e9..5d5165601 100644 --- a/app/src/i18n/locales/es/settings.json +++ b/app/src/i18n/locales/es/settings.json @@ -11,7 +11,7 @@ "city": "Ciudad", "full-name": "Nombre completo", "email": "Correo electrónico", - "manage-users": "gestionar usuarios", + "manage-users": "Gestionar usuarios", "enter-password-description": "Ingresa tu contraseña para confirmar que quieres eliminar la información de las ciudades", "incorrect-password": "¡Contraseña incorrecta! Por favor, inténtalo de nuevo", "role": "Rol", @@ -62,5 +62,14 @@ "delete-file-prompt": "¿Está seguro de que desea <2>eliminar permanentemente este archivo del repositorio de la ciudad?", "password-required": "Se requiere contraseña", "min-length": "La longitud mínima debe ser {{length}}", - "mark-as-completed": "Marcar como completado" + "mark-as-completed": "Marcar como completado", + "are-you-sure-you-want-to": "¿Estás seguro de que quieres", + "un-invite": "desinvitar", + "this-user-from-your-city": "a este usuario de tu ciudad?", + "user-details-update-fail": "No se pudieron actualizar los detalles del usuario", + "invite-canceled": "Invitación cancelada", + "invite-cancel-fail": "No se pudo cancelar la invitación", + "invite-sent": "Invitación enviada con éxito", + "invite-send-fail": "No se pudo enviar la invitación", + "number-of-cities": "# de ciudades" } diff --git a/app/src/i18n/locales/pt/settings.json b/app/src/i18n/locales/pt/settings.json index 9d5a13bf2..bf9d9d849 100644 --- a/app/src/i18n/locales/pt/settings.json +++ b/app/src/i18n/locales/pt/settings.json @@ -12,7 +12,7 @@ "enter-password-description": "Digite sua senha para confirmar a exclusão das informações das cidades", "incorrect-password": "Senha incorreta! Por favor, tente novamente", "full-name": "Nome Completo", - "manage-users": "gerenciar usuários", + "manage-users": "Gerenciar usuários", "search-filter-placeholder": "Pesquisar por nome ou endereço de e-mail", "all": "Todos", "select": "Selecionar", @@ -62,5 +62,14 @@ "delete-file-prompt": "Tem certeza de que deseja <2>excluir permanentemente este arquivo do repositório da cidade?", "mark-as-completed": "Marcar como concluído", "password-required": "A senha é obrigatória", - "min-length": "O comprimento mínimo deve ser {{length}}" + "min-length": "O comprimento mínimo deve ser {{length}}", + "are-you-sure-you-want-to": "Você tem certeza de que deseja", + "un-invite": "desconvidar", + "this-user-from-your-city": "este usuário da sua cidade?", + "user-details-update-fail": "Não foi possível atualizar os detalhes do usuário", + "invite-canceled": "Convite cancelado", + "invite-cancel-fail": "Não foi possível cancelar o convite", + "invite-sent": "Convite enviado com sucesso", + "invite-send-fail": "Não foi possível enviar o convite", + "number-of-cities": "# de cidades" } diff --git a/app/src/lib/app-theme.ts b/app/src/lib/app-theme.ts index b7317797b..fba40fe89 100644 --- a/app/src/lib/app-theme.ts +++ b/app/src/lib/app-theme.ts @@ -69,6 +69,7 @@ export const appTheme = extendTheme({ backgroundLight: "#FAFAFA", backgroundGreyFlat: "#FAFBFE", backgroundLoading: "#E8EAFB", + alternativeLight: "#F9FAFE", }, interactive: { diff --git a/app/src/models/CityInvite.ts b/app/src/models/CityInvite.ts index 18b19dd7d..35c33b597 100644 --- a/app/src/models/CityInvite.ts +++ b/app/src/models/CityInvite.ts @@ -1,13 +1,8 @@ import * as Sequelize from "sequelize"; import { DataTypes, Model, Optional } from "sequelize"; import { City, CityId } from "./City"; - -export enum CityInviteStatus { - PENDING = "pending", - ACCEPTED = "accepted", - CANCELED = "canceled", - EXPIRED = "expired", -} +import { User, UserId } from "./User"; +import { CityInviteStatus } from "@/util/types"; export interface CityInviteAttributes { id: string; @@ -54,6 +49,16 @@ export class CityInvite setCity!: Sequelize.BelongsToSetAssociationMixin; createCity!: Sequelize.BelongsToCreateAssociationMixin; + // CityInvite belongs to User via userId + user!: User; + getUser!: Sequelize.BelongsToGetAssociationMixin; + setUser!: Sequelize.BelongsToSetAssociationMixin; + + // CityInvite belongs to User via invitingUserId + invitingUser!: User; + getInvitingUser!: Sequelize.BelongsToGetAssociationMixin; + setInvitingUser!: Sequelize.BelongsToSetAssociationMixin; + static initModel(sequelize: Sequelize.Sequelize): typeof CityInvite { return CityInvite.init( { @@ -73,8 +78,13 @@ export class CityInvite field: "city_id", }, userId: { - type: DataTypes.STRING(255), + type: DataTypes.UUID, allowNull: true, + references: { + model: "User", + key: "id", + }, + field: "user_id", }, email: { type: DataTypes.STRING(255), diff --git a/app/src/models/init-models.ts b/app/src/models/init-models.ts index 14ca9057f..5e8802eb8 100644 --- a/app/src/models/init-models.ts +++ b/app/src/models/init-models.ts @@ -460,6 +460,11 @@ export function initModels(sequelize: Sequelize) { foreignKey: "userId", otherKey: "cityId", }); + CityInvite.belongsTo(User, { foreignKey: "userId", as: "user" }); + CityInvite.belongsTo(User, { + foreignKey: "invitingUserId", + as: "invitingUser", + }); User.belongsTo(Inventory, { as: "defaultInventory", foreignKey: "defaultInventoryId", diff --git a/app/src/services/api.ts b/app/src/services/api.ts index 016dff183..3b9166759 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -33,6 +33,7 @@ import { AcceptInviteResponse, AcceptInviteRequest, UsersInvitesResponse, + GetUserCityInvitesResponse, } from "@/util/types"; import type { GeoJSON } from "geojson"; import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; @@ -55,6 +56,7 @@ export const api = createApi({ "Inventory", "CitiesAndInventories", "Inventories", + "Invites", ], baseQuery: fetchBaseQuery({ baseUrl: "/api/v0/", credentials: "include" }), endpoints: (builder) => { @@ -402,11 +404,10 @@ export const api = createApi({ email: string; role: string; userId: string; - cityId: string; } >({ query: (data) => ({ - url: `/city/${data.cityId}/user/${data.userId}`, + url: `/user/${data.userId}`, method: "PATCH", body: data, }), @@ -436,28 +437,37 @@ export const api = createApi({ transformResponse: (response: { data: any }) => response.data, providesTags: ["UserData"], }), + getCityInvites: builder.query({ + query: () => `/user/invites`, + transformResponse: (response: { data: any }) => response.data, + providesTags: ["Invites"], + }), setUserData: builder.mutation< UserAttributes, - Partial & - Pick & { cityId: string } + Partial & Pick >({ - query: ({ userId, cityId, email, ...rest }) => ({ - url: `/city/${cityId}/user/${userId}`, + query: ({ userId, ...rest }) => ({ + url: `/user/${userId}`, method: "PATCH", body: rest, }), invalidatesTags: ["UserData"], }), - removeUser: builder.mutation< - UserAttributes, - { userId: string; cityId: string } - >({ - query: ({ cityId, userId }) => ({ - url: `/city/${cityId}/user/${userId}`, + cancelInvite: builder.mutation({ + query: ({ cityInviteId }) => ({ + url: `/user/invites/${cityInviteId}`, method: "DELETE", }), transformResponse: (response: { data: any }) => response.data, - invalidatesTags: ["UserData"], + invalidatesTags: ["Invites"], + }), + resetInvite: builder.mutation({ + query: ({ cityInviteId }) => ({ + url: `/user/invites/${cityInviteId}`, + method: "PATCH", + }), + transformResponse: (response: { data: any }) => response.data, + invalidatesTags: ["Invites"], }), getVerifcationToken: builder.query({ query: () => ({ @@ -584,7 +594,7 @@ export const api = createApi({ body: data, }; }, - + invalidatesTags: ["Invites"], transformResponse: (response: { data: UserInviteResponse }) => response.data, }), @@ -813,7 +823,8 @@ export const { useSetCurrentUserDataMutation, useGetCityUsersQuery, useSetUserDataMutation, - useRemoveUserMutation, + useCancelInviteMutation, + useResetInviteMutation, useRequestVerificationMutation, useGetVerifcationTokenQuery, useGetCitiesQuery, @@ -838,5 +849,6 @@ export const { useGetEmissionsForecastQuery, useUpdateInventoryMutation, useUpdateOrCreateInventoryValueMutation, + useGetCityInvitesQuery, } = api; export const { useGetOCCityQuery, useGetOCCityDataQuery } = openclimateAPI; diff --git a/app/src/util/types.ts b/app/src/util/types.ts index c9a0d344c..d819d1b70 100644 --- a/app/src/util/types.ts +++ b/app/src/util/types.ts @@ -191,6 +191,29 @@ export enum Roles { Admin = "admin", } +export interface GetUserCityInvitesResponseUserData { + userId: string; + role: Roles; + email: string; + name: string; +} + +export enum CityInviteStatus { + PENDING = "pending", + ACCEPTED = "accepted", + CANCELED = "canceled", + EXPIRED = "expired", +} + +export interface GetUserCityInvitesResponse { + id: string; + email: string; + user?: GetUserCityInvitesResponseUserData; + cityId: string; + userId: string; + status: CityInviteStatus; + cityInvites: Required; +} export interface AcceptInviteResponse { success: boolean; From a0d0b0088d1f5d57e5255710dd2e4bc9a134e6a8 Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Mon, 3 Feb 2025 12:04:50 -0300 Subject: [PATCH 4/7] feat: [ON-3354] expire invites --- app/src/app/api/v0/user/invites/route.ts | 15 +++++++++++++++ app/src/models/CityInvite.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/app/src/app/api/v0/user/invites/route.ts b/app/src/app/api/v0/user/invites/route.ts index ab0f7fce8..be2f44f3e 100644 --- a/app/src/app/api/v0/user/invites/route.ts +++ b/app/src/app/api/v0/user/invites/route.ts @@ -12,10 +12,13 @@ import { Op } from "sequelize"; import { logger } from "@/services/logger"; import { CityInviteStatus } from "@/util/types"; +import { subDays } from "date-fns"; + export const GET = apiHandler(async (req, { params, session }) => { if (!session) { throw new createHttpError.Unauthorized("Not signed in"); } + const invites = await db.models.CityInvite.findAll({ where: { invitingUserId: session?.user.id, @@ -36,6 +39,18 @@ export const GET = apiHandler(async (req, { params, session }) => { ], }); + const now = new Date(); + + for (const invite of invites) { + if ( + invite.status === CityInviteStatus.PENDING && + new Date(invite.lastUpdated!) < subDays(now, 30) + ) { + invite.status = CityInviteStatus.EXPIRED; + await invite.save(); + } + } + return NextResponse.json({ data: invites }); }); diff --git a/app/src/models/CityInvite.ts b/app/src/models/CityInvite.ts index 35c33b597..e933bc632 100644 --- a/app/src/models/CityInvite.ts +++ b/app/src/models/CityInvite.ts @@ -103,6 +103,11 @@ export class CityInvite type: DataTypes.STRING(255), allowNull: true, }, + lastUpdated: { + type: DataTypes.DATE, + allowNull: false, + field: "last_updated", + }, }, { sequelize, From c2fb7e7896837a6e7979234c5623fe756ea2edea Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Mon, 3 Feb 2025 14:12:01 -0300 Subject: [PATCH 5/7] feat: [ON-3355] fix --- app/src/models/CityInvite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/models/CityInvite.ts b/app/src/models/CityInvite.ts index e933bc632..378f1e714 100644 --- a/app/src/models/CityInvite.ts +++ b/app/src/models/CityInvite.ts @@ -105,7 +105,7 @@ export class CityInvite }, lastUpdated: { type: DataTypes.DATE, - allowNull: false, + allowNull: true, field: "last_updated", }, }, From 8ce9e6c0b3812a25df5b6b18cb8bac92c8fb3c1e Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Mon, 3 Feb 2025 14:12:54 -0300 Subject: [PATCH 6/7] feat: [ON-3355] default city --- .../inventory/[inventory]/progress/route.ts | 6 ++- .../v0/user/invites/[cityInviteId]/route.ts | 17 +++++++ app/src/backend/UserService.ts | 45 ++++++++++++++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/app/src/app/api/v0/inventory/[inventory]/progress/route.ts b/app/src/app/api/v0/inventory/[inventory]/progress/route.ts index ff1d0b7c1..461e2e57f 100644 --- a/app/src/app/api/v0/inventory/[inventory]/progress/route.ts +++ b/app/src/app/api/v0/inventory/[inventory]/progress/route.ts @@ -4,12 +4,16 @@ import { apiHandler } from "@/util/api"; import { NextResponse } from "next/server"; import InventoryProgressService from "@/backend/InventoryProgressService"; +import createHttpError from "http-errors"; export const GET = apiHandler(async (_req, { session, params }) => { + if (!session?.user.id) { + throw new createHttpError.Unauthorized("Unauthorized"); + } let inventoryId = params.inventory; if (inventoryId === "default") { - inventoryId = await UserService.findUserDefaultInventory(session); + inventoryId = await UserService.updateDefaultInventoryId(session.user.id); } const inventory = await UserService.findUserInventory( inventoryId, diff --git a/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts b/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts index 7fc11d656..0d75b213b 100644 --- a/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts +++ b/app/src/app/api/v0/user/invites/[cityInviteId]/route.ts @@ -4,6 +4,8 @@ import createHttpError from "http-errors"; import { NextResponse } from "next/server"; import { CityInviteStatus } from "@/util/types"; import { CityUser } from "@/models/CityUser"; +import { QueryTypes } from "sequelize"; +import UserService from "@/backend/UserService"; export const DELETE = apiHandler(async (req, { params, session }) => { if (!session) { @@ -29,6 +31,21 @@ export const DELETE = apiHandler(async (req, { params, session }) => { const cityUser = await CityUser.findOne({ where: { cityId: invite.cityId, userId: invite.userId }, }); + const [isDefaultCity] = await db.sequelize!.query( + ` + select "User".default_inventory_id + from "User" + join "Inventory" i on "User".default_inventory_id = i.inventory_id + join "CityUser" cu on "User".user_id = cu.user_id and cu.city_id = i.city_id + where city_user_id = :cityUserId`, + { + replacements: { cityInviteId }, + type: QueryTypes.SELECT, + }, + ); + if (isDefaultCity) { + await UserService.updateDefaultInventoryId(session.user.id); + } if (cityUser) { await cityUser.destroy(); } diff --git a/app/src/backend/UserService.ts b/app/src/backend/UserService.ts index 126d02285..719edcefc 100644 --- a/app/src/backend/UserService.ts +++ b/app/src/backend/UserService.ts @@ -6,8 +6,9 @@ import { type AppSession } from "@/lib/auth"; import type { City } from "@/models/City"; import type { Inventory } from "@/models/Inventory"; import type { User } from "@/models/User"; -import { col, Includeable } from "sequelize"; +import { Includeable } from "sequelize"; import { UserFile } from "@/models/UserFile"; +import { QueryTypes } from "sequelize"; export default class UserService { public static async findUser( @@ -121,6 +122,33 @@ export default class UserService { return inventory; } + public static async updateDefaultInventoryId(userId: string) { + const [inventory] = (await db.sequelize!.query( + ` + SELECT i.inventory_id + FROM "Inventory" i + JOIN "CityUser" cu ON i.city_id = cu.city_id + WHERE cu.user_id = :userId + ORDER BY i.last_updated DESC + LIMIT 1 + `, + { + replacements: { userId }, + type: QueryTypes.SELECT, + }, + )) as { inventory_id: string }[]; + if (!inventory) { + throw new createHttpError.NotFound("Inventory not found"); + } + await db.models.User.update( + { + defaultInventoryId: inventory?.inventory_id, + }, + { where: { userId } }, + ); + return inventory?.inventory_id; + } + /** * Load inventory information and perform access control */ @@ -128,15 +156,22 @@ export default class UserService { session: AppSession | null, ): Promise { if (!session) throw new createHttpError.Unauthorized("Unauthorized"); - + const userId = session.user.id; const user = await db.models.User.findOne({ - attributes: ["defaultInventoryId"], + attributes: ["defaultInventoryId", "userId"], where: { - userId: session.user.id, + userId, }, }); + if (!user) { + console.log("UserService:139"); // TODO NINA + throw new createHttpError.NotFound("User not found"); + } - if (!user || !user.defaultInventoryId) { + if (!user.defaultInventoryId) { + await UserService.updateDefaultInventoryId(user.userId); + } + if (!user.defaultInventoryId) { throw new createHttpError.NotFound("Inventory not found"); } From 4ab6b313460cfb43e3bda5a0fe1f734e2c53048e Mon Sep 17 00:00:00 2001 From: thehighestprimenumber Date: Mon, 3 Feb 2025 14:57:35 -0300 Subject: [PATCH 7/7] bug: [ON-2797] fix redirect --- app/src/app/[lng]/user/invites/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/app/[lng]/user/invites/page.tsx b/app/src/app/[lng]/user/invites/page.tsx index 11bfe92a9..404cc89b9 100644 --- a/app/src/app/[lng]/user/invites/page.tsx +++ b/app/src/app/[lng]/user/invites/page.tsx @@ -50,13 +50,13 @@ const AcceptInvitePage = ({ params: { lng } }: { params: { lng: string } }) => { const sanitizedEmail = sanitizeInput(email); const sanitizedCityIds = sanitizeInput(cityIds); - const { data, error } = await acceptInvite({ + const { error } = await acceptInvite({ token: sanitizedToken, cityIds: sanitizedCityIds.split(","), email: sanitizedEmail, }); - if (!data?.success || !!error) { + if (!!error) { setError(true); } else { router.push(`/`);