-
-
Notifications
You must be signed in to change notification settings - Fork 62
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #101 from sinamics/user_action
[Feature] Enhanced admin operations for platform users
- Loading branch information
Showing
30 changed files
with
1,344 additions
and
304 deletions.
There are no files selected for viewing
11 changes: 11 additions & 0 deletions
11
prisma/migrations/20230823185550_update_cascade_delete/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
19
prisma/migrations/20230825053528_user_invitation/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.