diff --git a/migrations/1774658921003_user_role.ts b/migrations/1774658921003_user_role.ts new file mode 100644 index 000000000..eaea7bb02 --- /dev/null +++ b/migrations/1774658921003_user_role.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { type Kysely, sql } from "kysely"; + +// `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. +export async function up(db: Kysely): Promise { + await db.schema.alterTable("user").addColumn("role", "text").execute(); + await sql`UPDATE "user" SET role = 'Superadmin' WHERE email = 'hello@commonknowledge.coop'`.execute( + db, + ); +} + +// `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. +export async function down(db: Kysely): Promise { + await db.schema.alterTable("user").dropColumn("role").execute(); +} diff --git a/migrations/1774658921004_data_source_config_org_unique.ts b/migrations/1774658921004_data_source_config_org_unique.ts new file mode 100644 index 000000000..4f7f667f6 --- /dev/null +++ b/migrations/1774658921004_data_source_config_org_unique.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Kysely } from "kysely"; + +// `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("dataSource") + .dropConstraint("data_source_config_key") + .execute(); + await db.schema + .alterTable("dataSource") + .addUniqueConstraint("data_source_config_organisation_id_key", [ + "config", + "organisationId", + ]) + .execute(); +} + +// `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("dataSource") + .dropConstraint("data_source_config_organisation_id_key") + .execute(); + await db.schema + .alterTable("dataSource") + .addUniqueConstraint("data_source_config_key", ["config"]) + .execute(); +} diff --git a/modules.d.ts b/modules.d.ts index 323f3ba29..e7421eb11 100644 --- a/modules.d.ts +++ b/modules.d.ts @@ -2,3 +2,4 @@ declare module "mapbox-gl/dist/style-spec/index.cjs" { export * from "mapbox-gl/dist/style-spec/index.d.ts"; } declare module "pg-cursor"; +declare module "*.css"; diff --git a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx new file mode 100644 index 000000000..bfc07d7c2 --- /dev/null +++ b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; +import { type FormEvent, useMemo, useState } from "react"; +import { toast } from "sonner"; +import FormFieldWrapper from "@/components/forms/FormFieldWrapper"; +import { useTRPC } from "@/services/trpc/react"; +import { Button } from "@/shadcn/ui/button"; +import { Checkbox } from "@/shadcn/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shadcn/ui/dialog"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; + +interface MapSelection { + mapId: string; + dataSourceIds: string[]; +} + +export default function CreateInvitationModal() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [organisationId, setOrganisationId] = useState(""); + const [organisationName, setOrganisationName] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [isCreatingNewOrg, setIsCreatingNewOrg] = useState(true); + const [selectedMapIds, setSelectedMapIds] = useState>(new Set()); + const [selectedDataSourceIds, setSelectedDataSourceIds] = useState< + Set + >(new Set()); + + const trpc = useTRPC(); + const client = useQueryClient(); + + const { data: organisations } = useQuery( + trpc.organisation.listAll.queryOptions(), + ); + const { data: mapData } = useQuery(trpc.map.listAll.queryOptions()); + + const { mutate: createInvitationMutate, isPending } = useMutation( + trpc.invitation.create.mutationOptions({ + onSuccess: () => { + toast.success("Invitation created successfully", { + description: "An invite has been sent to the user", + }); + resetForm(); + setDialogOpen(false); + client.invalidateQueries({ + queryKey: trpc.organisation.listAll.queryKey(), + }); + client.invalidateQueries({ + queryKey: trpc.invitation.list.queryKey(), + }); + }, + onError: (error) => { + toast.error("Failed to create invitation.", { + description: error.message, + }); + }, + }), + ); + + const resetForm = () => { + setName(""); + setEmail(""); + setOrganisationId(""); + setOrganisationName(""); + setIsCreatingNewOrg(true); + setSelectedMapIds(new Set()); + setSelectedDataSourceIds(new Set()); + }; + + // Collect data source IDs for each map + const dataSourceIdsByMap = useMemo(() => { + if (!mapData) return new Map>(); + const result = new Map>(); + for (const map of mapData.maps) { + const dsIds = new Set(); + for (const id of map.config.markerDataSourceIds) { + if (id) dsIds.add(id); + } + if (map.config.membersDataSourceId) { + dsIds.add(map.config.membersDataSourceId); + } + for (const view of map.views) { + if (view.config.areaDataSourceId) { + dsIds.add(view.config.areaDataSourceId); + } + for (const dsv of view.dataSourceViews) { + dsIds.add(dsv.dataSourceId); + } + } + result.set(map.id, dsIds); + } + return result; + }, [mapData]); + + const toggleMap = (mapId: string) => { + setSelectedMapIds((prev) => { + const next = new Set(prev); + if (next.has(mapId)) { + next.delete(mapId); + // Also deselect all data sources for this map + const dsIds = dataSourceIdsByMap.get(mapId); + if (dsIds) { + setSelectedDataSourceIds((prevDs) => { + const nextDs = new Set(prevDs); + for (const id of dsIds) nextDs.delete(id); + return nextDs; + }); + } + } else { + next.add(mapId); + // Auto-select all data sources for this map + const dsIds = dataSourceIdsByMap.get(mapId); + if (dsIds) { + setSelectedDataSourceIds((prevDs) => { + const nextDs = new Set(prevDs); + for (const id of dsIds) nextDs.add(id); + return nextDs; + }); + } + } + return next; + }); + }; + + const toggleDataSource = (dsId: string) => { + setSelectedDataSourceIds((prev) => { + const next = new Set(prev); + if (next.has(dsId)) { + next.delete(dsId); + } else { + next.add(dsId); + } + return next; + }); + }; + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!organisationId && !organisationName) { + toast.error("Please select an organisation or create a new one"); + return; + } + + const mapSelections: MapSelection[] = []; + for (const mapId of selectedMapIds) { + const dsIds = dataSourceIdsByMap.get(mapId); + if (!dsIds) continue; + mapSelections.push({ + mapId, + dataSourceIds: [...dsIds].filter((id) => selectedDataSourceIds.has(id)), + }); + } + + createInvitationMutate({ + organisationId, + organisationName, + email, + name, + mapSelections: mapSelections.length > 0 ? mapSelections : undefined, + }); + }; + + const toggleOrganisationMode = () => { + setIsCreatingNewOrg(!isCreatingNewOrg); + if (isCreatingNewOrg) { + setOrganisationName(""); + } else { + setOrganisationId(""); + } + }; + + // Group maps by organisation + const mapsByOrg = useMemo(() => { + if (!mapData) return []; + const groups = new Map(); + for (const map of mapData.maps) { + const orgName = map.organisationName; + const existing = groups.get(orgName); + if (existing) { + existing.push(map); + } else { + groups.set(orgName, [map]); + } + } + return [...groups.entries()].map(([orgName, maps]) => ({ orgName, maps })); + }, [mapData]); + + return ( + + + + + + + Create Invitation + + Send an invitation to a new user to join the platform. + + +
+ + setName(e.target.value)} + required + /> + + + + setEmail(e.target.value)} + required + /> + + + + Organisation + + + } + > + {isCreatingNewOrg ? ( + setOrganisationName(e.target.value)} + required + /> + ) : ( + + )} + + + {mapData && mapsByOrg.length > 0 && ( +
+ +

+ Select maps to copy to the new organisation. Untick data sources + you do not want to include. +

+
+ {mapsByOrg.map(({ orgName, maps }) => ( +
+

+ {orgName} +

+
+ {maps.map((map) => { + const dsIds = dataSourceIdsByMap.get(map.id); + return ( +
+
+ toggleMap(map.id)} + /> + +
+ {selectedMapIds.has(map.id) && + dsIds && + dsIds.size > 0 && ( +
+ {[...dsIds].map((dsId) => ( +
+ + toggleDataSource(dsId) + } + /> + +
+ ))} +
+ )} +
+ ); + })} +
+
+ ))} +
+
+ )} + + +
+
+
+ ); +} diff --git a/src/app/(private)/(dashboards)/invite-organisation/page.tsx b/src/app/(private)/(dashboards)/invite-organisation/page.tsx new file mode 100644 index 000000000..eed957ca0 --- /dev/null +++ b/src/app/(private)/(dashboards)/invite-organisation/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { redirect } from "next/navigation"; +import { useCurrentUser } from "@/hooks"; +import { UserRole } from "@/models/User"; +import { useTRPC } from "@/services/trpc/react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shadcn/ui/table"; +import CreateInvitationModal from "./CreateInvitationModal"; + +export default function InviteOrganisationPage() { + const { currentUser } = useCurrentUser(); + const trpc = useTRPC(); + const isAllowed = + currentUser?.role === UserRole.Advocate || + currentUser?.role === UserRole.Superadmin; + + const { data: invitations, isPending: invitationsLoading } = useQuery( + trpc.invitation.list.queryOptions(undefined, { enabled: isAllowed }), + ); + + if (!isAllowed) { + redirect("/"); + } + + if (invitationsLoading) { + return "Loading..."; + } + + return ( +
+
+

+ Invite Organisation +

+ +
+ +

Pending invitations

+ + + + Email + Name + Organisation + Created + + + + {invitations?.length === 0 ? ( + + + No pending invitations + + + ) : ( + invitations?.map((inv) => ( + + {inv.email} + {inv.name} + {inv.organisationName} + + {inv.createdAt + ? new Date(inv.createdAt).toLocaleDateString() + : "-"} + + + )) + )} + +
+
+ ); +} diff --git a/src/app/(private)/(dashboards)/superadmin/data-sources/[id]/page.tsx b/src/app/(private)/(dashboards)/superadmin/data-sources/[id]/page.tsx index 8d0c86832..21139a699 100644 --- a/src/app/(private)/(dashboards)/superadmin/data-sources/[id]/page.tsx +++ b/src/app/(private)/(dashboards)/superadmin/data-sources/[id]/page.tsx @@ -10,9 +10,9 @@ import { toast } from "sonner"; import EditColumnMetadataModal from "@/app/(private)/components/EditColumnMetadataModal/EditColumnMetadataModal"; import { useDataSourceListCache } from "@/app/(private)/hooks/useDataSourceListCache"; import { isSuperadminDataSourceRouteAtom } from "@/atoms/dataSourceAtoms"; -import { ADMIN_USER_EMAIL } from "@/constants"; import { useCurrentUser } from "@/hooks"; import { useDataSources } from "@/hooks/useDataSources"; +import { UserRole } from "@/models/User"; import { useTRPC } from "@/services/trpc/react"; import { uploadFile } from "@/services/uploads"; import { DefaultChoroplethSection } from "./components/DefaultChoroplethSection"; @@ -41,7 +41,7 @@ export default function DataSourceConfigPage() { const { data: dataSources, isPending, getDataSourceById } = useDataSources(); - if (currentUser && currentUser.email !== ADMIN_USER_EMAIL) redirect("/"); + if (currentUser && currentUser.role !== UserRole.Superadmin) redirect("/"); const dataSource = dataSources?.find((ds) => ds.id === id); diff --git a/src/app/(private)/(dashboards)/superadmin/page.tsx b/src/app/(private)/(dashboards)/superadmin/page.tsx index e3d4f54de..abcc25af1 100644 --- a/src/app/(private)/(dashboards)/superadmin/page.tsx +++ b/src/app/(private)/(dashboards)/superadmin/page.tsx @@ -1,25 +1,13 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { PlusIcon, Settings } from "lucide-react"; +import { Settings } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { type FormEvent, useState } from "react"; import { toast } from "sonner"; -import FormFieldWrapper from "@/components/forms/FormFieldWrapper"; -import { ADMIN_USER_EMAIL } from "@/constants"; import { useCurrentUser } from "@/hooks"; +import { UserRole } from "@/models/User"; import { useTRPC } from "@/services/trpc/react"; -import { Button } from "@/shadcn/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/shadcn/ui/dialog"; -import { Input } from "@/shadcn/ui/input"; import { Select, SelectContent, @@ -41,85 +29,30 @@ import { UserChart } from "./UserChart"; export default function SuperadminPage() { const { currentUser } = useCurrentUser(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [organisationId, setOrganisationId] = useState(""); - const [organisationName, setOrganisationName] = useState(""); - const [dialogOpen, setDialogOpen] = useState(false); - const [isCreatingNewOrg, setIsCreatingNewOrg] = useState(false); const trpc = useTRPC(); - const { data: organisations, isPending: organisationsLoading } = useQuery( - trpc.organisation.listAll.queryOptions(), - ); const { data: users, isPending: usersLoading } = useQuery( trpc.user.list.queryOptions(), ); - const { data: invitations, isPending: invitationsLoading } = useQuery( - trpc.invitation.list.queryOptions(), - ); const { data: publicDataSources, isPending: publicDataSourcesLoading } = useQuery(trpc.dataSource.listPublic.queryOptions()); const client = useQueryClient(); - const { mutate: createInvitationMutate, isPending } = useMutation( - trpc.invitation.create.mutationOptions({ + const { mutate: updateRole } = useMutation( + trpc.user.updateRole.mutationOptions({ onSuccess: () => { - toast.success("Invitation created successfully", { - description: "An invite has been sent to the user", - }); - setName(""); - setEmail(""); - setOrganisationId(""); - setOrganisationName(""); - setIsCreatingNewOrg(false); - setDialogOpen(false); - client.invalidateQueries({ - queryKey: trpc.organisation.listAll.queryKey(), - }); - client.invalidateQueries({ - queryKey: trpc.user.list.queryKey(), - }); - client.invalidateQueries({ - queryKey: trpc.invitation.list.queryKey(), - }); + client.invalidateQueries({ queryKey: trpc.user.list.queryKey() }); + toast.success("Role updated"); }, onError: (error) => { - toast.error("Failed to create invitation.", { - description: error.message, - }); + toast.error("Failed to update role.", { description: error.message }); }, }), ); - if (currentUser?.email !== ADMIN_USER_EMAIL) redirect("/"); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - - if (!organisationId && !organisationName) { - toast.error("Please select an organisation or create a new one"); - return; - } - createInvitationMutate({ organisationId, organisationName, email, name }); - }; + if (currentUser?.role !== UserRole.Superadmin) redirect("/"); - const toggleOrganisationMode = () => { - setIsCreatingNewOrg(!isCreatingNewOrg); - // Clear the state of whichever mode we're leaving - if (isCreatingNewOrg) { - setOrganisationName(""); - } else { - setOrganisationId(""); - } - }; - - if ( - organisationsLoading || - usersLoading || - invitationsLoading || - publicDataSourcesLoading - ) { + if (usersLoading || publicDataSourcesLoading) { return "Loading..."; } @@ -127,10 +60,9 @@ export default function SuperadminPage() {
-

Superadmin

+

Superadmin

All Users - Pending Invitations Public Library Feature Access @@ -147,6 +79,7 @@ export default function SuperadminPage() { Email Name Organisation + Role @@ -155,149 +88,32 @@ export default function SuperadminPage() { {u.email} {u.name} {u.organisations.join(", ")} - - ))} - - - - -
-

Pending Invitations

- - - - - - - - Create Invitation - - Send an invitation to a new user to join the platform. - - -
- - setName(e.target.value)} - required - /> - - - - setEmail(e.target.value)} - required - /> - - - - Organisation - -
- } - > - {isCreatingNewOrg ? ( - setOrganisationName(e.target.value)} - required - /> - ) : ( - - )} - - - - - - -
- - - - Email - Name - Organisation - Created - - - - {invitations?.length === 0 ? ( - - - No pending invitations + + - ) : ( - invitations?.map((inv) => ( - - {inv.email} - {inv.name} - {inv.organisationName} - - {inv.createdAt - ? new Date(inv.createdAt).toLocaleDateString() - : "-"} - - - )) - )} + ))}
diff --git a/src/app/(private)/map/[id]/components/Map.css b/src/app/(private)/map/[id]/components/Map.css index 9b1a42146..e3202fbc2 100644 --- a/src/app/(private)/map/[id]/components/Map.css +++ b/src/app/(private)/map/[id]/components/Map.css @@ -12,7 +12,7 @@ overflow: hidden; } -/* search inout styles */ +/* search input styles */ .map-wrapper .mapboxgl-ctrl-geocoder.mapboxgl-ctrl { background: transparent; box-shadow: none; diff --git a/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index b01ba9cdb..07c6eb8ea 100644 --- a/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -290,7 +290,9 @@ export default function VisualisationPanel({