This commit is contained in:
Alex Holliday
2026-02-09 17:52:14 +00:00
parent 1552018796
commit d69cec9730
5 changed files with 154 additions and 32 deletions
+14
View File
@@ -0,0 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { inviteSchema, type InviteFormData } from "@/Validation/invite";
export const useInviteForm = () => {
const defaults: InviteFormData = {
email: "",
role: ["user"],
};
return {
resolver: zodResolver(inviteSchema),
defaults,
};
};
@@ -60,7 +60,7 @@ export const HeaderTeamControls = ({
startIcon={<Icon icon={Mail} />}
onClick={onInviteClick}
>
{t("pages.account.team.inviteMember")}
{t("common.buttons.inviteMember")}
</Button>
)}
{isSuperAdmin && (
@@ -69,7 +69,7 @@ export const HeaderTeamControls = ({
color="primary"
startIcon={<Icon icon={UserPlus} />}
>
{t("pages.account.team.addMember")}
{t("common.buttons.addMember")}
</Button>
)}
</Stack>
@@ -1,9 +1,20 @@
import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Select, Button } from "@/Components/v2/inputs";
import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
interface InviteResponse {
token: string;
}
interface InviteTeamMemberDialogProps {
open: boolean;
@@ -16,29 +27,70 @@ export const InviteTeamMemberDialog = ({
}: InviteTeamMemberDialogProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { resolver, defaults } = useInviteForm();
const { control, handleSubmit, reset } = useForm<InviteFormData>({
resolver,
defaultValues: defaults,
values: defaults,
});
const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData,
InviteResponse
>();
const { post: sendInvite, loading: sendLoading } = usePost<
InviteFormData,
InviteResponse
>();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
];
const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data);
if (result?.data?.token) {
const token = result.data.token;
const link = CLIENT_HOST ? `${CLIENT_HOST}/register/${token}` : token;
setInviteLink(link);
}
};
const handleSendInvite = async (data: InviteFormData) => {
const result = await sendInvite("/invite/send", data);
if (result?.success) {
handleClose();
}
};
const handleClose = () => {
reset();
setInviteLink(null);
onClose();
};
return (
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={onClose}
onConfirm={() => {}}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
confirmText={t("pages.account.team.invite.sendInvite")}
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={() => {}}
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("pages.account.team.invite.generateToken")}
{t("common.buttons.generateToken")}
</Button>
}
>
@@ -46,27 +98,66 @@ export const InviteTeamMemberDialog = ({
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<TextField
fieldLabel={t("pages.account.team.invite.email.label")}
placeholder={t("pages.account.team.invite.email.placeholder")}
type="email"
fullWidth
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.account.team.invite.email.label")}
placeholder={t("pages.account.team.invite.email.placeholder")}
type="email"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Select
fieldLabel={t("pages.account.team.invite.role.label")}
placeholder={t("pages.account.team.invite.role.placeholder")}
defaultValue="user"
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<>
<Select
{...field}
value={Array.isArray(value) ? (value[0] ?? "") : ""}
onChange={(e) => onChange([e.target.value as UserRole])}
fieldLabel={t("pages.account.team.invite.role.label")}
placeholder={t("pages.account.team.invite.role.placeholder")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{inviteLink && (
<>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
);
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
import { UserRoles } from "@/Types/User";
export const inviteSchema = z.object({
email: z.email("Please enter a valid email address"),
role: z.array(z.enum(UserRoles)).min(1, "Please select a role"),
});
export type InviteFormData = z.infer<typeof inviteSchema>;
+12 -4
View File
@@ -167,6 +167,7 @@
"uploadSuccess": "Monitors created successfully!"
},
"bytesSent": "Bytes Sent",
"addMember": "Add member",
"cancel": "Cancel",
"checkHooks": {
"failureResolveOne": "Failed to resolve incident."
@@ -195,16 +196,20 @@
"home": "Home"
},
"buttons": {
"addMember": "Add member",
"cancel": "Cancel",
"close": "Close",
"configure": "Configure",
"confirm": "Confirm",
"create": "Create",
"delete": "Delete",
"generateToken": "Generate token",
"incidents": "Incidents",
"inviteMember": "Invite member",
"pause": "Pause",
"resume": "Resume",
"save": "Save",
"sendInvite": "Send invite",
"test": "Test",
"testNotifications": "Test notifications",
"toggleTheme": "Toggles light & dark",
@@ -299,6 +304,7 @@
"createMonitor": "Create monitor",
"createNew": "Create new",
"delete": "Delete",
"generateToken": "Generate token",
"DeleteAccountButton": "Remove account",
"DeleteAccountTitle": "Remove account",
"DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.",
@@ -459,6 +465,7 @@
"discussions": "Discussions",
"docs": "Docs",
"incidents": "Incidents",
"inviteMember": "Invite member",
"infrastructure": "Infrastructure",
"logOut": "Log out",
"logs": "Logs",
@@ -559,8 +566,6 @@
}
},
"team": {
"addMember": "Add member",
"inviteMember": "Invite member",
"filter": {
"placeholder": "Filter by role",
"all": "All roles",
@@ -584,8 +589,7 @@
"label": "Role",
"placeholder": "Select a role"
},
"sendInvite": "Send invite",
"generateToken": "Generate token"
"linkLabel": "Invite link"
}
}
},
@@ -675,8 +679,10 @@
"actions": {
"configure": "Configure",
"delete": "Delete",
"generateToken": "Generate token",
"details": "Details",
"incidents": "Incidents",
"inviteMember": "Invite member",
"openSite": "Open site",
"pause": "Pause",
"resume": "Resume"
@@ -1341,6 +1347,7 @@
"teamMember": "Team member"
},
"save": "Save",
"sendInvite": "Send invite",
"selectAll": "Select all",
"settingsFailedToClearStats": "Failed to clear stats",
"settingsFailedToDeleteMonitors": "Failed to delete all monitors",
@@ -1467,6 +1474,7 @@
"description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.",
"title": "Register new team member"
},
"addMember": "Add member",
"cancel": "Cancel",
"changeTeamPassword": {
"changePasswordMenu": "Reset Password",