diff --git a/server/routers/external.ts b/server/routers/external.ts index b543faac..addd922b 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -26,12 +26,7 @@ import { verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, -<<<<<<< Updated upstream verifyIsLoggedInUser -======= - verifyIsLoggedInUser, - verifyClientAccess ->>>>>>> Stashed changes } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -51,10 +46,6 @@ unauthenticated.get("/", (_, res) => { export const authenticated = Router(); authenticated.use(verifySessionUserMiddleware); -<<<<<<< Updated upstream -======= -authenticated.get("/pick-org-defaults", org.pickOrgDefaults); ->>>>>>> Stashed changes authenticated.get("/org/checkId", org.checkId); authenticated.put("/org", getUserOrgs, org.createOrg); @@ -530,8 +521,7 @@ authenticated.post( authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); -authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps); - +authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); // Auth routes diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx deleted file mode 100644 index 5ebc34cb..00000000 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ /dev/null @@ -1,369 +0,0 @@ -"use client"; - -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { ListRolesResponse } from "@server/routers/role"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Checkbox } from "@app/components/ui/checkbox"; - -type InviteUserFormProps = { - open: boolean; - setOpen: (open: boolean) => void; -}; - -const formSchema = z.object({ - email: z.string().email({ message: "Invalid email address" }), - validForHours: z.string().min(1, { message: "Please select a duration" }), - roleId: z.string().min(1, { message: "Please select a role" }) -}); - -export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { - const { org } = useOrgContext(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - - const [inviteLink, setInviteLink] = useState(null); - const [loading, setLoading] = useState(false); - const [expiresInDays, setExpiresInDays] = useState(1); - const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); - - const validFor = [ - { hours: 24, name: "1 day" }, - { hours: 48, name: "2 days" }, - { hours: 72, name: "3 days" }, - { hours: 96, name: "4 days" }, - { hours: 120, name: "5 days" }, - { hours: 144, name: "6 days" }, - { hours: 168, name: "7 days" } - ]; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - validForHours: "72", - roleId: "" - } - }); - - useEffect(() => { - if (open) { - setSendEmail(env.email.emailEnabled); - form.reset(); - setInviteLink(null); - setExpiresInDays(1); - } - }, [open, env.email.emailEnabled, form]); - - useEffect(() => { - if (!open) { - return; - } - - async function fetchRoles() { - const res = await api - .get< - AxiosResponse - >(`/org/${org?.org.orgId}/roles`) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: "Failed to fetch roles", - description: formatAxiosError( - e, - "An error occurred while fetching the roles" - ) - }); - }); - - if (res?.status === 200) { - setRoles(res.data.data.roles); - } - } - - fetchRoles(); - }, [open]); - - async function onSubmit(values: z.infer) { - setLoading(true); - - const res = await api - .post>( - `/org/${org?.org.orgId}/create-invite`, - { - email: values.email, - roleId: parseInt(values.roleId), - validHours: parseInt(values.validForHours), - sendEmail: sendEmail - } as InviteUserBody - ) - .catch((e) => { - if (e.response?.status === 409) { - toast({ - variant: "destructive", - title: "User Already Exists", - description: - "This user is already a member of the organization." - }); - } else { - toast({ - variant: "destructive", - title: "Failed to invite user", - description: formatAxiosError( - e, - "An error occurred while inviting the user" - ) - }); - } - }); - - if (res && res.status === 200) { - setInviteLink(res.data.data.inviteLink); - toast({ - variant: "default", - title: "User invited", - description: "The user has been successfully invited." - }); - - setExpiresInDays(parseInt(values.validForHours) / 24); - } - - setLoading(false); - } - - return ( - <> - { - setOpen(val); - if (!val) { - setInviteLink(null); - setLoading(false); - setExpiresInDays(1); - form.reset(); - } - }} - > - - - Invite User - - Give new users access to your organization - - - -
- {!inviteLink && ( -
- - ( - - Email - - - - - - )} - /> - - {env.email.emailEnabled && ( -
- - setSendEmail( - e as boolean - ) - } - /> - -
- )} - - ( - - Role - - - - )} - /> - ( - - - Valid For - - - - - )} - /> - - - )} - - {inviteLink && ( -
- {sendEmail && ( -

- An email has been sent to the user - with the access link below. They - must access the link to accept the - invitation. -

- )} - {!sendEmail && ( -

- The user has been invited. They must - access the link below to accept the - invitation. -

- )} -

- The invite will expire in{" "} - - {expiresInDays}{" "} - {expiresInDays === 1 - ? "day" - : "days"} - - . -

- -
- )} -
-
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx index 1ce169e0..643d8641 100644 --- a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx @@ -24,7 +24,7 @@ export function UsersDataTable({ searchPlaceholder="Search users..." searchColumn="email" onAdd={inviteUser} - addButtonText="Invite User" + addButtonText="Create User" /> ); } diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index ea642800..8036cc84 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -11,7 +11,6 @@ import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; import { UsersDataTable } from "./UsersDataTable"; import { useState } from "react"; -import InviteUserForm from "./InviteUserForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; @@ -41,16 +40,11 @@ type UsersTableProps = { }; export default function UsersTable({ users: u }: UsersTableProps) { - const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [users, setUsers] = useState(u); - const router = useRouter(); - const api = createApiClient(useEnvContext()); - const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); @@ -281,16 +275,11 @@ export default function UsersTable({ users: u }: UsersTableProps) { title="Remove User from Organization" /> - - { - setIsInviteModalOpen(true); + router.push(`/${org?.org.orgId}/settings/access/users/create`); }} /> diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx new file mode 100644 index 00000000..c2e9374d --- /dev/null +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { ListRolesResponse } from "@server/routers/role"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Checkbox } from "@app/components/ui/checkbox"; + +type UserType = "internal" | "external"; + +interface UserTypeOption { + id: UserType; + title: string; + description: string; +} + +const formSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + validForHours: z.string().min(1, { message: "Please select a duration" }), + roleId: z.string().min(1, { message: "Please select a role" }) +}); + +export default function Page() { + const { orgId } = useParams(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [userType, setUserType] = useState("internal"); + const [inviteLink, setInviteLink] = useState(null); + const [loading, setLoading] = useState(false); + const [expiresInDays, setExpiresInDays] = useState(1); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); + + const validFor = [ + { hours: 24, name: "1 day" }, + { hours: 48, name: "2 days" }, + { hours: 72, name: "3 days" }, + { hours: 96, name: "4 days" }, + { hours: 120, name: "5 days" }, + { hours: 144, name: "6 days" }, + { hours: 168, name: "7 days" } + ]; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + validForHours: "72", + roleId: "" + } + }); + + useEffect(() => { + if (userType === "internal") { + setSendEmail(env.email.emailEnabled); + form.reset(); + setInviteLink(null); + setExpiresInDays(1); + } + }, [userType, env.email.emailEnabled, form]); + + useEffect(() => { + if (userType !== "internal") { + return; + } + + async function fetchRoles() { + const res = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch roles", + description: formatAxiosError( + e, + "An error occurred while fetching the roles" + ) + }); + }); + + if (res?.status === 200) { + setRoles(res.data.data.roles); + } + } + + fetchRoles(); + }, [userType]); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleId: parseInt(values.roleId), + validHours: parseInt(values.validForHours), + sendEmail: sendEmail + } as InviteUserBody + ) + .catch((e) => { + if (e.response?.status === 409) { + toast({ + variant: "destructive", + title: "User Already Exists", + description: + "This user is already a member of the organization." + }); + } else { + toast({ + variant: "destructive", + title: "Failed to invite user", + description: formatAxiosError( + e, + "An error occurred while inviting the user" + ) + }); + } + }); + + if (res && res.status === 200) { + setInviteLink(res.data.data.inviteLink); + toast({ + variant: "default", + title: "User invited", + description: "The user has been successfully invited." + }); + + setExpiresInDays(parseInt(values.validForHours) / 24); + } + + setLoading(false); + } + + const userTypes: ReadonlyArray = [ + { + id: "internal", + title: "Internal User", + description: "Invite a user to join your organization directly." + }, + { + id: "external", + title: "External User", + description: + "Provision a user with an external identity provider (IdP)." + } + ]; + + return ( + <> +
+ + +
+ +
+ + + + + User Type + + + Determine how you want to create the user + + + + { + setUserType(value as UserType); + }} + cols={2} + /> + + + + {userType === "internal" && ( + + + + User Information + + + Enter the details for the new user + + + + +
+ + ( + + + Email + + + + + + + )} + /> + + {env.email.emailEnabled && ( +
+ + setSendEmail( + e as boolean + ) + } + /> + +
+ )} + + ( + + + Role + + + + + )} + /> + + ( + + + Valid For + + + + + )} + /> + + {inviteLink && ( +
+ {sendEmail && ( +

+ An email has been + sent to the user + with the access link + below. They must + access the link to + accept the + invitation. +

+ )} + {!sendEmail && ( +

+ The user has been + invited. They must + access the link + below to accept the + invitation. +

+ )} +

+ The invite will expire + in{" "} + + {expiresInDays}{" "} + {expiresInDays === 1 + ? "day" + : "days"} + + . +

+ +
+ )} + + +
+
+
+ )} +
+ +
+ + {userType === "internal" && ( + + )} +
+
+ + ); +}