diff --git a/frontend/src/models/admin.js b/frontend/src/models/admin.js index 0798a3e73..df27ac02c 100644 --- a/frontend/src/models/admin.js +++ b/frontend/src/models/admin.js @@ -64,10 +64,14 @@ const Admin = { return []; }); }, - newInvite: async () => { + newInvite: async ({ role = null, workspaceIds = null }) => { return await fetch(`${API_BASE}/admin/invite/new`, { - method: "GET", + method: "POST", headers: baseHeaders(), + body: JSON.stringify({ + role, + workspaceIds, + }), }) .then((res) => res.json()) .catch((e) => { diff --git a/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx b/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx index 3aef87a65..e69da4ae7 100644 --- a/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx +++ b/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx @@ -1,16 +1,23 @@ import React, { useEffect, useState } from "react"; import { X } from "@phosphor-icons/react"; import Admin from "@/models/admin"; +import Workspace from "@/models/workspace"; export default function NewInviteModal({ closeModal }) { const [invite, setInvite] = useState(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const [workspaces, setWorkspaces] = useState([]); + const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState([]); const handleCreate = async (e) => { setError(null); e.preventDefault(); - const { invite: newInvite, error } = await Admin.newInvite(); + + const { invite: newInvite, error } = await Admin.newInvite({ + role: null, + workspaceIds: selectedWorkspaceIds, + }); if (!!newInvite) setInvite(newInvite); setError(error); }; @@ -21,6 +28,16 @@ export default function NewInviteModal({ closeModal }) { ); setCopied(true); }; + + const handleWorkspaceSelection = (workspaceId) => { + if (selectedWorkspaceIds.includes(workspaceId)) { + const updated = selectedWorkspaceIds.filter((id) => id !== workspaceId); + setSelectedWorkspaceIds(updated); + return; + } + setSelectedWorkspaceIds([...selectedWorkspaceIds, workspaceId]); + }; + useEffect(() => { function resetStatus() { if (!copied) return false; @@ -31,6 +48,15 @@ export default function NewInviteModal({ closeModal }) { resetStatus(); }, [copied]); + useEffect(() => { + async function fetchWorkspaces() { + Workspace.all() + .then((workspaces) => setWorkspaces(workspaces)) + .catch(() => setWorkspaces([])); + } + fetchWorkspaces(); + }, []); + return ( <div className="relative w-[500px] max-w-2xl max-h-full"> <div className="relative bg-main-gradient rounded-lg shadow"> @@ -61,11 +87,45 @@ export default function NewInviteModal({ closeModal }) { )} <p className="text-white text-xs md:text-sm"> After creation you will be able to copy the invite and send it - to a new user where they can create an account as a default - user. + to a new user where they can create an account as the{" "} + <b>default</b> role and automatically be added to workspaces + selected. </p> </div> </div> + + {workspaces.length > 0 && !invite && ( + <div className="p-6 flex w-full justify-between"> + <div className="w-full"> + <div className="flex flex-col gap-y-1 mb-2"> + <label + htmlFor="workspaces" + className="text-sm font-medium text-white" + > + Auto-add invitee to workspaces + </label> + <p className="text-white/60 text-xs"> + You can optionally automatically assign the user to the + workspaces below by selecting them. By default, the user + will not have any workspaces visible. You can assign + workspaces later post-invite acceptance. + </p> + </div> + + <div className="flex flex-col gap-y-2"> + {workspaces.map((workspace) => ( + <WorkspaceOption + key={workspace.id} + workspace={workspace} + selected={selectedWorkspaceIds.includes(workspace.id)} + toggleSelection={handleWorkspaceSelection} + /> + ))} + </div> + </div> + </div> + )} + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50"> {!invite ? ( <> @@ -99,3 +159,31 @@ export default function NewInviteModal({ closeModal }) { </div> ); } + +function WorkspaceOption({ workspace, selected, toggleSelection }) { + return ( + <button + type="button" + onClick={() => toggleSelection(workspace.id)} + className={`transition-all duration-300 w-full h-11 p-2.5 bg-white/10 rounded-lg flex justify-start items-center gap-2.5 cursor-pointer border border-transparent ${ + selected ? "border-white border-opacity-40" : "border-none " + } hover:border-white/60`} + > + <input + type="radio" + name="workspace" + value={workspace.id} + checked={selected} + className="hidden" + /> + <div + className={`w-4 h-4 rounded-full border-2 border-white mr-2 ${ + selected ? "bg-white" : "" + }`} + ></div> + <div className="text-white text-sm font-medium font-['Plus Jakarta Sans'] leading-tight"> + {workspace.name} + </div> + </button> + ); +} diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index 792cf2dd9..f55cbb6e7 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -165,13 +165,18 @@ function adminEndpoints(app) { } ); - app.get( + app.post( "/admin/invite/new", [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], async (request, response) => { try { const user = await userFromSession(request, response); - const { invite, error } = await Invite.create(user.id); + const body = reqBody(request); + const { invite, error } = await Invite.create({ + createdByUserId: user.id, + workspaceIds: body?.workspaceIds || [], + }); + await EventLogs.logEvent( "invite_created", { diff --git a/server/endpoints/api/admin/index.js b/server/endpoints/api/admin/index.js index e91672e00..228777ab5 100644 --- a/server/endpoints/api/admin/index.js +++ b/server/endpoints/api/admin/index.js @@ -323,6 +323,18 @@ function apiAdminEndpoints(app) { /* #swagger.tags = ['Admin'] #swagger.description = 'Create a new invite code for someone to use to register with instance. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Request body for creation parameters of the invitation', + required: false, + type: 'object', + content: { + "application/json": { + example: { + workspaceIds: [1,2,45], + } + } + } + } #swagger.responses[200] = { content: { "application/json": { @@ -355,7 +367,10 @@ function apiAdminEndpoints(app) { return; } - const { invite, error } = await Invite.create(); + const body = reqBody(request); + const { invite, error } = await Invite.create({ + workspaceIds: body?.workspaceIds ?? [], + }); response.status(200).json({ invite, error }); } catch (e) { console.error(e); diff --git a/server/models/invite.js b/server/models/invite.js index ff9ae8687..781a9434f 100644 --- a/server/models/invite.js +++ b/server/models/invite.js @@ -1,3 +1,4 @@ +const { safeJsonParse } = require("../utils/http"); const prisma = require("../utils/prisma"); const Invite = { @@ -6,12 +7,13 @@ const Invite = { return uuidAPIKey.create().apiKey; }, - create: async function (createdByUserId = 0) { + create: async function ({ createdByUserId = 0, workspaceIds = [] }) { try { const invite = await prisma.invites.create({ data: { code: this.makeCode(), createdBy: createdByUserId, + workspaceIds: JSON.stringify(workspaceIds), }, }); return { invite, error: null }; @@ -23,7 +25,7 @@ const Invite = { deactivate: async function (inviteId = null) { try { - const invite = await prisma.invites.update({ + await prisma.invites.update({ where: { id: Number(inviteId) }, data: { status: "disabled" }, }); @@ -40,6 +42,26 @@ const Invite = { where: { id: Number(inviteId) }, data: { status: "claimed", claimedBy: user.id }, }); + + try { + if (!!invite?.workspaceIds) { + const { Workspace } = require("./workspace"); + const { WorkspaceUser } = require("./workspaceUsers"); + const workspaceIds = (await Workspace.where({})).map( + (workspace) => workspace.id + ); + const ids = safeJsonParse(invite.workspaceIds) + .map((id) => Number(id)) + .filter((id) => workspaceIds.includes(id)); + if (ids.length !== 0) await WorkspaceUser.createMany(user.id, ids); + } + } catch (e) { + console.error( + "Could not add user to workspaces automatically", + e.message + ); + } + return { success: true, error: null }; } catch (error) { console.error(error.message); diff --git a/server/prisma/migrations/20240326231053_init/migration.sql b/server/prisma/migrations/20240326231053_init/migration.sql new file mode 100644 index 000000000..85fe8be75 --- /dev/null +++ b/server/prisma/migrations/20240326231053_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "invites" ADD COLUMN "workspaceIds" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index e6121e297..fbb5f61d4 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -41,6 +41,7 @@ model invites { code String @unique status String @default("pending") claimedBy Int? + workspaceIds String? createdAt DateTime @default(now()) createdBy Int lastUpdatedAt DateTime @default(now()) @@ -100,7 +101,7 @@ model workspaces { chatModel String? topN Int? @default(4) chatMode String? @default("chat") - pfpFilename String? + pfpFilename String? workspace_users workspace_users[] documents workspace_documents[] workspace_suggested_messages workspace_suggested_messages[] diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json index 77dc974ad..e0ee35a56 100644 --- a/server/swagger/openapi.json +++ b/server/swagger/openapi.json @@ -489,6 +489,22 @@ "500": { "description": "Internal Server Error" } + }, + "requestBody": { + "description": "Request body for creation parameters of the invitation", + "required": false, + "type": "object", + "content": { + "application/json": { + "example": { + "workspaceIds": [ + 1, + 2, + 45 + ] + } + } + } } } }, diff --git a/server/utils/files/logo.js b/server/utils/files/logo.js index 681084010..68c56c217 100644 --- a/server/utils/files/logo.js +++ b/server/utils/files/logo.js @@ -53,8 +53,16 @@ async function renameLogoFile(originalFilename = null) { const extname = path.extname(originalFilename) || ".png"; const newFilename = `${v4()}${extname}`; const originalFilepath = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets", normalizePath(originalFilename)) - : path.join(__dirname, `../../storage/assets`, normalizePath(originalFilename)); + ? path.join( + process.env.STORAGE_DIR, + "assets", + normalizePath(originalFilename) + ) + : path.join( + __dirname, + `../../storage/assets`, + normalizePath(originalFilename) + ); const outputFilepath = process.env.STORAGE_DIR ? path.join(process.env.STORAGE_DIR, "assets", normalizePath(newFilename)) : path.join(__dirname, `../../storage/assets`, normalizePath(newFilename)); diff --git a/server/utils/http/index.js b/server/utils/http/index.js index 83e3fa5dd..084b09c7e 100644 --- a/server/utils/http/index.js +++ b/server/utils/http/index.js @@ -61,6 +61,13 @@ function parseAuthHeader(headerValue = null, apiKey = null) { return { [headerValue]: apiKey }; } +function safeJsonParse(jsonString, fallback = null) { + try { + return JSON.parse(jsonString); + } catch {} + return fallback; +} + module.exports = { reqBody, multiUserMode, @@ -69,4 +76,5 @@ module.exports = { decodeJWT, userFromSession, parseAuthHeader, + safeJsonParse, };