Skip to content

Commit

Permalink
Merge pull request #101 from sinamics/user_action
Browse files Browse the repository at this point in the history
[Feature] Enhanced admin operations for platform users
  • Loading branch information
sinamics authored Aug 25, 2023
2 parents eda9e6d + 9aeb730 commit 559e604
Show file tree
Hide file tree
Showing 30 changed files with 1,344 additions and 304 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "UserOptions" DROP CONSTRAINT "UserOptions_userId_fkey";

-- DropForeignKey
ALTER TABLE "network" DROP CONSTRAINT "network_authorId_fkey";

-- AddForeignKey
ALTER TABLE "network" ADD CONSTRAINT "network_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "UserOptions" ADD CONSTRAINT "UserOptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19 changes: 19 additions & 0 deletions prisma/migrations/20230825053528_user_invitation/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "UserInvitation" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"email" TEXT,
"secret" TEXT NOT NULL,
"url" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"timesCanUse" INTEGER NOT NULL DEFAULT 1,
"timesUsed" INTEGER NOT NULL DEFAULT 0,
"createdBy" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "UserInvitation_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "UserInvitation_token_key" ON "UserInvitation"("token");
19 changes: 17 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ model network {
lastModifiedTime DateTime?
flowRule String?
autoAssignIp Boolean? @default(true)
nw_userid User @relation(fields: [authorId], references: [id])
nw_userid User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
tagsByName Json?
capabilitiesByName Json?
Expand Down Expand Up @@ -138,7 +138,7 @@ model Session {
model UserOptions {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
//networks
useNotationColorAsBg Boolean? @default(false)
Expand Down Expand Up @@ -205,6 +205,21 @@ model VerificationToken {
@@unique([identifier, token])
}

model UserInvitation {
id Int @id @default(autoincrement())
token String @unique
used Boolean @default(false)
email String?
secret String
url String
expires DateTime
timesCanUse Int @default(1)
timesUsed Int @default(0)
createdBy Int
createdAt DateTime @default(now())
}


// To map your data model to the database schema, you need to use the prisma migrate CLI commands:
// npx prisma migrate dev --name (NAME)

Expand Down
73 changes: 73 additions & 0 deletions src/components/admin/users/userGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { User } from "@prisma/client";
// import { useTranslations } from "next-intl";
import React from "react";
import toast from "react-hot-toast";
import { ErrorData } from "~/types/errorHandling";
import { api } from "~/utils/api";
// import { useModalStore } from "~/utils/store";

interface Iuser {
user: Partial<User>;
}
const UserRole = ({ user }: Iuser) => {
// const t = useTranslations("admin");
const { data: usergroups } = api.admin.getUserGroups.useQuery();
// will update the users table as it uses key "getUsers"
// !TODO should rework to update local cache instead.. but this works for now
const { refetch: refetchUsers } = api.admin.getUsers.useQuery({
isAdmin: false,
});

// Updates this modal as it uses key "getUser"
// !TODO should rework to update local cache instead.. but this works for now
const { refetch: refetchUser } = api.admin.getUser.useQuery({
userId: user?.id,
});

const { mutate: assignUserGroup } = api.admin.assignUserGroup.useMutation({
onError: (error) => {
if ((error.data as ErrorData)?.zodError) {
const fieldErrors = (error.data as ErrorData)?.zodError.fieldErrors;
for (const field in fieldErrors) {
toast.error(`${fieldErrors[field].join(", ")}`);
}
} else if (error.message) {
toast.error(error.message);
} else {
toast.error("An unknown error occurred");
}
},
onSuccess: () => {
toast.success("Group added successfully");

refetchUser();
refetchUsers();
},
});

return (
<div className="form-control w-full max-w-xs">
<select
value={user?.userGroupId ?? "None"}
onChange={(e) => {
assignUserGroup({
userid: user?.id,
userGroupId: e.target.value,
});
}}
className="select select-sm select-bordered select-ghost max-w-xs"
>
<option value="none">None</option>
{usergroups?.map((group) => {
return (
<option key={group.id} value={group.id}>
{group.name}
</option>
);
})}
</select>
</div>
);
};

export default UserRole;
223 changes: 223 additions & 0 deletions src/components/admin/users/userInvitation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import React from "react";
import InputFields from "~/components/elements/inputField";
import { api } from "~/utils/api";
import TimeAgo from "react-timeago";
import { useModalStore } from "~/utils/store";
import { CopyToClipboard } from "react-copy-to-clipboard";
import toast from "react-hot-toast";
import cn from "classnames";
import { useTranslations } from "next-intl";

const InvitationLink = () => {
const t = useTranslations();
const { callModal, closeModal } = useModalStore((state) => state);
const { mutate: deleteInvitation } = api.admin.deleteInvitationLink.useMutation();

const { data: invitationLink, refetch: refetchInvitations } =
api.admin.getInvitationLink.useQuery();

const showInviationDetails = (invite) => {
const expired = new Date(invite.expires) < new Date();
callModal({
title: t("admin.users.authentication.generateInvitation.invitationModal.header"),
rootStyle: "text-left",
showButtons: true,
closeModalOnSubmit: true,
content: (
<div>
<p>
<span className="text-gray-400">
{t(
"admin.users.authentication.generateInvitation.invitationModal.secretLabel",
)}
</span>
<span className="text-primary pl-1">{invite.secret}</span>
</p>
<p>
<span className="text-gray-400">
{t(
"admin.users.authentication.generateInvitation.invitationModal.expiresLabel",
)}{" "}
</span>
{expired ? (
<span className="text-error">Expired</span>
) : (
<span>
Expires in <TimeAgo date={invite.expires} />
</span>
)}
</p>
<p>
<span className="text-gray-400">
{t(
"admin.users.authentication.generateInvitation.invitationModal.timesUsedLabel",
)}{" "}
</span>
{`${invite.timesUsed}/${invite.timesCanUse || 1}`}
</p>
<div>
<span className="text-gray-400">
{t(
"admin.users.authentication.generateInvitation.invitationModal.invitationLinkLabel",
)}
</span>
<CopyToClipboard
text={invite.url}
onCopy={() =>
toast.success(
t(
"admin.users.authentication.generateInvitation.invitationModal.linkCopiedToast",
),
)
}
title={"copyToClipboard.title"}
>
<div
style={{ wordWrap: "break-word" }}
className="cursor-pointer text-blue-500"
>
<p className="pt-5 text-sm text-gray-400">
{t(
"admin.users.authentication.generateInvitation.invitationModal.clickToCopyLabel",
)}
</p>
{invite.url}
</div>
</CopyToClipboard>
</div>
<div className="py-10">
<button
onClick={() => {
deleteInvitation(
{ id: invite.id },
{
onSuccess: () => {
void refetchInvitations();
closeModal();
},
},
);
}}
className="btn btn-sm btn-error btn-outline"
>
{t(
"admin.users.authentication.generateInvitation.invitationModal.deleteButton",
)}
</button>
</div>
</div>
),
});
};
return (
<div>
{invitationLink?.length > 0 ? (
<>
<p className="pt-5 text-sm text-gray-400">
{t("admin.users.authentication.generateInvitation.activeInvitationsLabel")}
</p>
<div className="flex gap-3">
{invitationLink?.map((link) => {
const expired = new Date(link.expires) < new Date();
return (
<div
onClick={() => showInviationDetails(link)}
className="cursor-pointer"
>
<p
className={cn("text-md badge", {
"bg-primary": !expired,
"bg-error": expired,
})}
>
{link.secret}
<span className="pl-1">
{`${link.timesUsed}/${link.timesCanUse || 1}`} --{" "}
</span>
{`${expired ? "Expired" : "Expires in"}`}
{!expired && (
<span className="pl-1">
<TimeAgo date={link.expires} />
</span>
)}
</p>
</div>
);
})}
</div>{" "}
</>
) : null}
</div>
);
};
const UserInvitation = () => {
const t = useTranslations();

const { mutate: generateInvitation } = api.admin.generateInviteLink.useMutation();
const { refetch: refetchInvitations } = api.admin.getInvitationLink.useQuery();
const { data: options } = api.admin.getAllOptions.useQuery();
return (
<div className="pt-5">
<InputFields
disabled={options?.enableRegistration}
isLoading={false}
label={t("admin.users.authentication.generateInvitation.header")}
rootFormClassName="flex flex-col space-y-2 w-6/6"
size="sm"
placeholder=""
buttonText={t("changeButton.generate")}
fields={[
{
name: "secret",
type: "text",
description: t(
"admin.users.authentication.generateInvitation.secretMessageLabel",
),
placeholder: t(
"admin.users.authentication.generateInvitation.secretMessagePlaceholder",
),
defaultValue: "",
},
{
name: "expireTime",
type: "number",
placeholder: t(
"admin.users.authentication.generateInvitation.expireTimePlaceholder",
),
description: t(
"admin.users.authentication.generateInvitation.expireTimeLabel",
),
defaultValue: "",
},
{
name: "timesCanUse",
type: "number",
placeholder: t(
"admin.users.authentication.generateInvitation.timeUsedPlaceholder",
),
description: t("admin.users.authentication.generateInvitation.timeUsedLabel"),
defaultValue: "",
},
]}
submitHandler={(params) => {
return new Promise((resolve) => {
void generateInvitation(
{
...params,
},
{
onSuccess: () => {
void refetchInvitations();
resolve(true);
},
},
);
});
}}
/>
<InvitationLink />
</div>
);
};

export default UserInvitation;
Loading

0 comments on commit 559e604

Please sign in to comment.