Merge branch 'auth-providers' into dev

This commit is contained in:
miloschwartz
2025-04-23 22:08:37 -04:00
93 changed files with 5788 additions and 1608 deletions

View File

@@ -8,7 +8,8 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { orgNavItems } from "../navigation";
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org";
type OrgPageProps = {
params: Promise<{ orgId: string }>;
@@ -43,12 +44,23 @@ export default async function OrgPage(props: OrgPageProps) {
redirect(`/${orgId}/settings`);
}
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
await authCookieHeader()
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<UserProvider user={user}>
<Layout
orgId={orgId}
navItems={orgNavItems}
>
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
{overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard

View File

@@ -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<string | null>(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<z.infer<typeof formSchema>>({
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<ListRolesResponse>
>(`/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<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/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 (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setInviteLink(null);
setLoading(false);
setExpiresInDays(1);
form.reset();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Invite User</CredenzaTitle>
<CredenzaDescription>
Give new users access to your organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
{!inviteLink && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="invite-user-form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to user
</label>
</div>
)}
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(option) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has been sent to the user
with the access link below. They
must access the link to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has been invited. They must
access the link below to accept the
invitation.
</p>
)}
<p>
The invite will expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1
? "day"
: "days"}
</b>
.
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
loading={loading}
disabled={inviteLink !== null || loading}
>
Create Invitation
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -24,7 +24,7 @@ export function UsersDataTable<TData, TValue>({
searchPlaceholder="Search users..."
searchColumn="email"
onAdd={inviteUser}
addButtonText="Invite User"
addButtonText="Create User"
/>
);
}

View File

@@ -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";
@@ -24,7 +23,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
export type UserRow = {
id: string;
email: string;
email: string | null;
displayUsername: string | null;
username: string;
name: string | null;
idpId: number | null;
idpName: string;
type: string;
status: string;
role: string;
isOwner: boolean;
@@ -35,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<UserRow | null>(null);
const [users, setUsers] = useState<UserRow[]>(u);
const router = useRouter();
const api = createApiClient(useEnvContext());
const { user, updateUser } = useUserContext();
const { org } = useOrgContext();
@@ -82,7 +82,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
Manage User
</DropdownMenuItem>
</Link>
{userRow.email !== user?.email && (
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${userRow.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
@@ -108,7 +109,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
},
{
accessorKey: "email",
accessorKey: "displayUsername",
header: ({ column }) => {
return (
<Button
@@ -117,14 +118,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Email
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "status",
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
@@ -133,7 +134,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Status
Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -185,7 +186,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
<Button
variant={"outlinePrimary"}
className="ml-2"
>
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -239,7 +243,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<div className="space-y-4">
<p>
Are you sure you want to remove{" "}
<b>{selectedUser?.email}</b> from the organization?
<b>
{selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username}
</b>{" "}
from the organization?
</p>
<p>
@@ -250,27 +259,27 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</p>
<p>
To confirm, please type the email address of the
user below.
To confirm, please type the name of the of the user
below.
</p>
</div>
}
buttonText="Confirm Remove User"
onConfirm={removeUser}
string={selectedUser?.email ?? ""}
string={
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
}
title="Remove User from Organization"
/>
<InviteUserForm
open={isInviteModalOpen}
setOpen={setIsInviteModalOpen}
/>
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
setIsInviteModalOpen(true);
router.push(`/${org?.org.orgId}/settings/access/users/create`);
}}
/>
</>

View File

@@ -0,0 +1,793 @@
"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 { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
type UserType = "internal" | "oidc";
interface UserTypeOption {
id: UserType;
title: string;
description: string;
}
interface IdpOption {
idpId: number;
name: string;
type: string;
}
const internalFormSchema = 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" })
});
const externalFormSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
email: z
.string()
.email({ message: "Invalid email address" })
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: "Please select a role" }),
idpId: z.string().min(1, { message: "Please select an identity provider" })
});
const formatIdpType = (type: string) => {
switch (type.toLowerCase()) {
case "oidc":
return "Generic OAuth2/OIDC provider.";
default:
return type;
}
};
export default function Page() {
const { orgId } = useParams();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [userType, setUserType] = useState<UserType | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
const [dataLoaded, setDataLoaded] = useState(false);
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 internalForm = useForm<z.infer<typeof internalFormSchema>>({
resolver: zodResolver(internalFormSchema),
defaultValues: {
email: "",
validForHours: "72",
roleId: ""
}
});
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
resolver: zodResolver(externalFormSchema),
defaultValues: {
username: "",
email: "",
name: "",
roleId: "",
idpId: ""
}
});
useEffect(() => {
if (userType === "internal") {
setSendEmail(env.email.emailEnabled);
internalForm.reset();
setInviteLink(null);
setExpiresInDays(1);
} else if (userType === "oidc") {
externalForm.reset();
}
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
useEffect(() => {
if (!userType) {
return;
}
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/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);
if (userType === "internal") {
setDataLoaded(true);
}
}
}
async function fetchIdps() {
const res = await api
.get<AxiosResponse<ListIdpsResponse>>("/idp")
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch identity providers",
description: formatAxiosError(
e,
"An error occurred while fetching identity providers"
)
});
});
if (res?.status === 200) {
setIdps(res.data.data.idps);
setDataLoaded(true);
}
}
setDataLoaded(false);
fetchRoles();
if (userType !== "internal") {
fetchIdps();
}
}, [userType]);
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/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);
}
async function onSubmitExternal(
values: z.infer<typeof externalFormSchema>
) {
setLoading(true);
const res = await api
.put(`/org/${orgId}/user`, {
username: values.username,
email: values.email,
name: values.name,
type: "oidc",
idpId: parseInt(values.idpId),
roleId: parseInt(values.roleId)
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create user",
description: formatAxiosError(
e,
"An error occurred while creating the user"
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: "User created",
description: "The user has been successfully created."
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
const userTypes: ReadonlyArray<UserTypeOption> = [
{
id: "internal",
title: "Internal User",
description: "Invite a user to join your organization directly."
},
{
id: "oidc",
title: "External User",
description: "Create a user with an external identity provider."
}
];
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Create User"
description="Follow the steps below to create a new user"
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
See All Users
</Button>
</div>
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Type
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to create the user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={userTypes}
defaultValue={userType || undefined}
onChange={(value) => {
setUserType(value as UserType);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
}
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
{userType === "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to
user
</label>
</div>
)}
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has
been sent to the
user with the
access link
below. They must
access the link
to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has
been invited.
They must access
the link below
to accept the
invitation.
</p>
)}
<p>
The invite will
expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays ===
1
? "day"
: "days"}
</b>
.
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{userType !== "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Identity Provider
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the identity provider for the
external user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
No identity providers are
configured. Please configure an
identity provider before creating
external users.
</p>
) : (
<Form {...externalForm}>
<FormField
control={externalForm.control}
name="idpId"
render={({ field }) => (
<FormItem>
<StrategySelect
options={idps.map(
(idp) => ({
id: idp.idpId.toString(),
title: idp.name,
description:
formatIdpType(
idp.type
)
})
)}
defaultValue={
field.value
}
onChange={(
value
) => {
field.onChange(
value
);
const idp =
idps.find(
(idp) =>
idp.idpId.toString() ===
value
);
setSelectedIdp(
idp || null
);
}}
cols={3}
/>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
</SettingsSectionBody>
</SettingsSection>
{idps.length > 0 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...externalForm}>
<form
onSubmit={externalForm.handleSubmit(
onSubmitExternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
externalForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
Username
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
This must
match the
unique
username
that exists
in the
selected
identity
provider.
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
Cancel
</Button>
{userType && dataLoaded && (
<Button
type="submit"
form="create-user-form"
loading={loading}
disabled={
loading ||
(userType === "internal" && inviteLink !== null)
}
>
Create User
</Button>
)}
</div>
</div>
</>
);
}

View File

@@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) {
const userRows: UserRow[] = users.map((user) => {
return {
id: user.id,
username: user.username,
displayUsername: user.email || user.name || user.username,
name: user.name,
email: user.email,
type: user.type,
idpId: user.idpId,
idpName: user.idpName || "Internal",
status: "Confirmed",
role: user.isOwner ? "Owner" : user.roleName || "Member",
isOwner: user.isOwner || false

View File

@@ -31,7 +31,7 @@ import {
CardTitle
} from "@/components/ui/card";
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { redirect, useRouter } from "next/navigation";
import {
SettingsContainer,
@@ -43,6 +43,7 @@ import {
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
const GeneralFormSchema = z.object({
name: z.string()
@@ -57,6 +58,7 @@ export default function GeneralPage() {
const router = useRouter();
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const { user } = useUserContext();
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
@@ -101,7 +103,9 @@ export default function GeneralPage() {
async function pickNewOrgAndNavigate() {
try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
const res = await api.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`
);
if (res.status === 200) {
if (res.data.data.orgs.length > 0) {
@@ -237,9 +241,7 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Danger Zone
</SettingsSectionTitle>
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
<SettingsSectionDescription>
Once you delete this org, there is no going back. Please
be certain.

View File

@@ -11,7 +11,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
@@ -62,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}`);
}
let orgs: ListOrgsResponse["orgs"] = [];
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
cookie
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {

View File

@@ -21,10 +21,8 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
@@ -58,10 +56,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [selectedResource, setSelectedResource] = useState<ResourceRow | null>();
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
@@ -282,11 +278,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
return (
<>
<CreateResourceForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
/>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
@@ -328,7 +319,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
columns={columns}
data={resources}
createResource={() => {
setIsCreateModalOpen(true);
router.push(`/${orgId}/settings/resources/create`);
}}
/>
</>

View File

@@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({
/>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
</FormItem>
)}
/>

View File

@@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
obscured
/>
<InputOTPSlot
index={1}
obscured
/>
<InputOTPSlot
index={2}
obscured
/>
<InputOTPSlot
index={3}
obscured
/>
<InputOTPSlot
index={4}
obscured
/>
<InputOTPSlot
index={5}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
</FormDescription>
</FormItem>
)}
/>

View File

@@ -45,6 +45,9 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -175,7 +178,7 @@ export default function ResourceAuthenticationPage() {
setAllUsers(
usersResponse.data.data.users.map((user) => ({
id: user.id.toString(),
text: user.email
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}))
);
@@ -183,7 +186,7 @@ export default function ResourceAuthenticationPage() {
"users",
resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(),
text: i.email
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}))
);
@@ -611,117 +614,127 @@ export default function ResourceAuthenticationPage() {
</SettingsSectionBody>
</SettingsSection>
{env.email.emailEnabled && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
SMTP Required
</AlertTitle>
<AlertDescription>
SMTP must be enabled on the server to use one-time password authentication.
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);