Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions src/interfaces/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ export interface UserInviteCreateRequest {
expires_in?: number;
}

export interface UserInviteCreateResponse {
export interface UserInvite {
id: string;
email: string;
name: string;
role: string;
auto_groups: string[];
status: string;
invite_link: string;
invite_expires_at: string;
expires_at: string;
created_at: string;
expired: boolean;
invite_token?: string;
}

export interface UserInviteInfo {
Expand All @@ -52,15 +53,13 @@ export interface UserInviteAcceptResponse {
success: boolean;
}

export interface UserInviteListItem {
id: string;
email: string;
name: string;
role: string;
auto_groups: string[];
expires_at: string;
created_at: string;
expired: boolean;
export interface UserInviteRegenerateRequest {
expires_in?: number;
}

export interface UserInviteRegenerateResponse {
invite_token: string;
invite_expires_at: string;
}

export enum Role {
Expand Down
14 changes: 7 additions & 7 deletions src/modules/users/UserInviteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import Avatar2 from "@/assets/avatars/030.jpg";
import Avatar3 from "@/assets/avatars/063.jpg";
import Avatar4 from "@/assets/avatars/086.jpg";
import { Group } from "@/interfaces/Group";
import { Role, User, UserInviteCreateResponse } from "@/interfaces/User";
import { Role, User, UserInvite } from "@/interfaces/User";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
import { isNetBirdHosted } from "@utils/netbird";
Expand All @@ -43,7 +43,7 @@ const inviteLinkCopyMessage = "Invite link was copied to your clipboard!";

type SuccessData =
| { type: "password"; user: User }
| { type: "invite"; invite: UserInviteCreateResponse };
| { type: "invite"; invite: UserInvite };

export default function UserInviteModal({ children, groups }: Readonly<Props>) {
const [open, setOpen] = useState(false);
Expand All @@ -57,7 +57,7 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
const getInviteFullUrl = () => {
if (!isInviteSuccess) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${successData.invite.invite_link}`;
return `${origin}/invite?token=${successData.invite.invite_token}`;
};
Comment on lines 57 to 61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against missing invite_token when building the URL.

invite_token is optional in UserInvite, so this can generate token=undefined and a broken invite link. Consider short‑circuiting if the token is absent (and encode it when present).

🛠️ Proposed fix
-  const getInviteFullUrl = () => {
+  const getInviteFullUrl = () => {
     if (!isInviteSuccess) return "";
     const origin = typeof window !== "undefined" ? window.location.origin : "";
-    return `${origin}/invite?token=${successData.invite.invite_token}`;
+    const token = successData.invite.invite_token;
+    if (!token) return "";
+    return `${origin}/invite?token=${encodeURIComponent(token)}`;
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getInviteFullUrl = () => {
if (!isInviteSuccess) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${successData.invite.invite_link}`;
return `${origin}/invite?token=${successData.invite.invite_token}`;
};
const getInviteFullUrl = () => {
if (!isInviteSuccess) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
const token = successData.invite.invite_token;
if (!token) return "";
return `${origin}/invite?token=${encodeURIComponent(token)}`;
};
🤖 Prompt for AI Agents
In `@src/modules/users/UserInviteModal.tsx` around lines 57 - 61, The
getInviteFullUrl function can return a URL with token=undefined because
successData.invite.invite_token is optional; update getInviteFullUrl to first
verify isInviteSuccess and that successData?.invite?.invite_token is a non-empty
string, short-circuiting to "" if absent, and when present use
encodeURIComponent(successData.invite.invite_token) to build the URL (keep the
existing origin handling using window.location.origin).


const getCopyValue = () => {
Expand All @@ -79,7 +79,7 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
}, 1000);
};

const handleInviteCreated = (invite: UserInviteCreateResponse) => {
const handleInviteCreated = (invite: UserInvite) => {
setSuccessData({ type: "invite", invite });
setSuccessModal(true);
setTimeout(() => {
Expand Down Expand Up @@ -165,7 +165,7 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
{isInviteSuccess && (
<Paragraph className={"mt-3 text-xs text-nb-gray-400 text-center"}>
Expires on{" "}
{new Date(successData.invite.invite_expires_at).toLocaleString()}
{new Date(successData.invite.expires_at).toLocaleString()}
</Paragraph>
)}
</div>
Expand All @@ -187,7 +187,7 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {

type ModalProps = {
onUserCreated: (user: User) => void;
onInviteCreated: (invite: UserInviteCreateResponse) => void;
onInviteCreated: (invite: UserInvite) => void;
groups?: Group[];
};

Expand All @@ -197,7 +197,7 @@ export function UserInviteModalContent({
groups = [],
}: Readonly<ModalProps>) {
const userRequest = useApiCall<User>("/users");
const inviteRequest = useApiCall<UserInviteCreateResponse>("/users/invites");
const inviteRequest = useApiCall<UserInvite>("/users/invites");
const { mutate } = useSWRConfig();

const [name, setName] = useState("");
Expand Down
31 changes: 13 additions & 18 deletions src/modules/users/UserInvitesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ import useCopyToClipboard from "@/hooks/useCopyToClipboard";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { cn, generateColorFromString } from "@utils/helpers";
import { Group } from "@/interfaces/Group";
import { Role, UserInviteListItem } from "@/interfaces/User";
import { Role, UserInvite, UserInviteRegenerateResponse } from "@/interfaces/User";
import UserInviteModal from "@/modules/users/UserInviteModal";
import { useAccount } from "@/modules/account/useAccount";

// Name cell for invites - same styling as UserNameCell but for invites
function InviteNameCell({ invite }: { invite: UserInviteListItem }) {
function InviteNameCell({ invite }: { invite: UserInvite }) {
return (
<div
className={cn("flex gap-4 px-2 py-1 items-center")}
Expand All @@ -73,7 +73,7 @@ function InviteNameCell({ invite }: { invite: UserInviteListItem }) {
}

// Role cell for invites - same styling as UserRoleCell but for invites
function InviteRoleCell({ invite }: { invite: UserInviteListItem }) {
function InviteRoleCell({ invite }: { invite: UserInvite }) {
const role = invite.role as Role;

return (
Expand Down Expand Up @@ -121,25 +121,20 @@ function InviteRoleCell({ invite }: { invite: UserInviteListItem }) {
}

// Regenerate cell for invites - button to regenerate invite link with modal
type RegenerateResponse = {
invite_link: string;
invite_expires_at: string;
};

function InviteRegenerateCell({ invite }: { invite: UserInviteListItem }) {
function InviteRegenerateCell({ invite }: { invite: UserInvite }) {
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [modalOpen, setModalOpen] = useState(false);
const [regeneratedData, setRegeneratedData] = useState<RegenerateResponse | null>(null);
const [regeneratedData, setRegeneratedData] = useState<UserInviteRegenerateResponse | null>(null);

const regenerateRequest = useApiCall<RegenerateResponse>(
const regenerateRequest = useApiCall<UserInviteRegenerateResponse>(
`/users/invites/${invite.id}/regenerate`,
);

const getInviteFullUrl = () => {
if (!regeneratedData) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${regeneratedData.invite_link}`;
return `${origin}/invite?token=${regeneratedData.invite_token}`;
};

const [, copyToClipboard] = useCopyToClipboard(getInviteFullUrl());
Expand Down Expand Up @@ -238,7 +233,7 @@ function InviteRegenerateCell({ invite }: { invite: UserInviteListItem }) {
}

// Groups cell for invites - read-only display of auto_groups
function InviteGroupCell({ invite }: { invite: UserInviteListItem }) {
function InviteGroupCell({ invite }: { invite: UserInvite }) {
const { groups, isLoading } = useGroups();

const foundGroups = useMemo(() => {
Expand Down Expand Up @@ -266,7 +261,7 @@ function InviteGroupCell({ invite }: { invite: UserInviteListItem }) {
}

// Status cell for invites - shows Valid/Expired based on expired field
function InviteStatusCell({ invite }: { invite: UserInviteListItem }) {
function InviteStatusCell({ invite }: { invite: UserInvite }) {
const isExpired = invite.expired;
const text = isExpired ? "Expired" : "Valid";
const color = isExpired ? "bg-red-500" : "bg-green-500";
Expand All @@ -283,10 +278,10 @@ function InviteStatusCell({ invite }: { invite: UserInviteListItem }) {
}

// Action cell for invites - delete invite
function InviteActionCell({ invite }: { invite: UserInviteListItem }) {
function InviteActionCell({ invite }: { invite: UserInvite }) {
const { confirm } = useDialog();
const { permission } = usePermissions();
const inviteRequest = useApiCall<UserInviteListItem>("/users/invites");
const inviteRequest = useApiCall<UserInvite>("/users/invites");
const { mutate } = useSWRConfig();

const deleteInvite = async () => {
Expand Down Expand Up @@ -332,7 +327,7 @@ function InviteActionCell({ invite }: { invite: UserInviteListItem }) {
);
}

export const InvitesTableColumns: ColumnDef<UserInviteListItem>[] = [
export const InvitesTableColumns: ColumnDef<UserInvite>[] = [
{
accessorKey: "name",
header: ({ column }) => {
Expand Down Expand Up @@ -403,7 +398,7 @@ export default function UserInvitesTable({
onShowUsers,
}: Readonly<Props>) {
useFetchApi("/groups");
const { data: invites, isLoading } = useFetchApi<UserInviteListItem[]>("/users/invites");
const { data: invites, isLoading } = useFetchApi<UserInvite[]>("/users/invites");
const { mutate } = useSWRConfig();
const path = usePathname();

Expand Down