Skip to content

Local user invites#539

Merged
braginini merged 13 commits intomainfrom
feature/local-user-invites
Jan 25, 2026
Merged

Local user invites#539
braginini merged 13 commits intomainfrom
feature/local-user-invites

Conversation

@braginini
Copy link
Copy Markdown
Contributor

@braginini braginini commented Jan 24, 2026

Screenshot 2026-01-24 at 16 38 11 Screenshot 2026-01-24 at 16 38 35 Screenshot 2026-01-24 at 16 38 54 Screenshot 2026-01-24 at 16 38 44

Summary by CodeRabbit

  • New Features
    • Full user invitation system: admins can send expiring invite links, regenerate/delete links, and track status.
    • Invite management UI: table with search, sorting, group display, regenerate/delete flows, and empty-state guidance.
    • Invite acceptance flow: public invite page to set password with live validation, expiry handling, and success/error screens.
    • Dual-mode user modal: create user or send invite, with copy-to-clipboard and expiry options.
    • Version indicator: dashboard shows Management/Dashboard versions and update CTA for self-hosted.
  • Activity
    • Activity feed logs invite events (create, accept, regenerate, delete).

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 24, 2026

📝 Walkthrough

Walkthrough

Adds a complete invite subsystem: public invite acceptance page and layout, unauthenticated invite APIs, invite-related types, UI for creating/managing invites (modal + table), activity log entries for invite events, auth bypass for /invite routes, and version display + CI versioning updates.

Changes

Cohort / File(s) Summary
Invite Route & Page
src/app/invite/layout.tsx, src/app/invite/page.tsx
New invite layout and client page. Reads token from URL, fetches invite info, validates password strength/match, calls acceptInvite, and renders loading / invalid / expired / success / error states.
Unauthenticated API
src/utils/unauthenticatedApi.ts
Added fetchInviteInfo(token) and acceptInvite(token, password) using existing unauthenticatedRequest wrapper.
Auth Provider
src/auth/OIDCProvider.tsx
OIDCProvider now treats paths starting with /invite as public and bypasses initialization for those routes.
Interfaces / Types
src/interfaces/User.ts, src/interfaces/Instance.ts
Added invite-related interfaces (UserInviteCreateRequest, UserInviteCreateResponse, UserInviteInfo, UserInviteAcceptRequest, UserInviteAcceptResponse, UserInviteListItem) and VersionInfo.
Invite Management UI
src/modules/users/UserInviteModal.tsx, src/modules/users/UserInvitesTable.tsx, src/modules/users/UsersTable.tsx
New dual-mode UserInviteModal (create vs invite) with onUserCreated/onInviteCreated, invite expiry input; comprehensive UserInvitesTable with regenerate/delete flows, SWR updates, exported InvitesTableColumns and InviteUserButton; UsersTable toggles to show invites and adjusts invite/add labels.
Activity Logging
src/modules/activity/ActivityDescription.tsx
Added activity branches for user.invite.link.create, user.invite.link.accept, user.invite.link.regenerate, user.invite.link.delete.
Versioning & CI
src/components/VersionInfo.tsx, src/layouts/Navigation.tsx, next.config.js, .github/workflows/build_and_push.yml
New NavigationVersionInfo component and VersionInfo type, Navigation renders the component, NEXT_PUBLIC_DASHBOARD_VERSION added to config, workflow step emits build version and sets NEXT_PUBLIC_DASHBOARD_VERSION for the build.

Sequence Diagram(s)

sequenceDiagram
    actor InvitedUser as Invited User
    participant InvitePage as Invite Page (client)
    participant APIClient as Unauthenticated API Helpers
    participant Backend as Backend

    InvitedUser->>InvitePage: Open /invite?token=XYZ
    InvitePage->>APIClient: fetchInviteInfo(token)
    APIClient->>Backend: GET /users/invites/{token}
    Backend-->>APIClient: UserInviteInfo (email,name,expires_at,valid,invited_by)
    APIClient-->>InvitePage: Invite info
    InvitePage->>InvitedUser: Render form + password rules
    InvitedUser->>InvitePage: Enter password & confirm
    InvitePage->>InvitePage: Validate rules & match
    InvitedUser->>InvitePage: Click "Accept Invite"
    InvitePage->>APIClient: acceptInvite(token, password)
    APIClient->>Backend: POST /users/invites/{token}/accept {password}
    alt Success
        Backend-->>APIClient: { success: true }
        APIClient-->>InvitePage: Success
        InvitePage->>InvitedUser: Show success and redirect to login
    else Error
        Backend-->>APIClient: Error
        APIClient-->>InvitePage: Error
        InvitePage->>InvitedUser: Show error message
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • heisbrot

Poem

🐰 Hopping in with invites so bright,
Links and modals glow in soft moonlight,
Password rules checked, no step amiss,
Regenerate, delete, and send a kiss,
New users bloom — the dashboard's delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Local user invites' clearly and concisely describes the primary change—adding a local user invites feature. The title directly maps to the changeset content, which implements invite creation, acceptance, and management functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/modules/users/UserInviteModal.tsx (1)

206-277: Validate expiresIn before converting to seconds.

Line 249 uses parseInt on user input without JS-side validation. While the Input component has HTML5 min={1}, this is not reliable across browsers. Empty, invalid, or NaN values can reach the backend as expires_in, potentially causing immediate expiry or rejection. Additionally, the isDisabled check does not account for invalid expiry values, allowing submission in invite mode when expiresIn is invalid.

Clamp the value to ≥1 and add validation to isDisabled to prevent submit when the expiry is invalid in invite mode.

🐛 Suggested fix
 const createInvite = async () => {
   const groups = await saveGroups();
   const groupIds = groups.map((group) => group.id) as string[];
+  const expiresDays = Math.max(
+    1,
+    Number.parseInt(expiresIn, 10) || 1,
+  );
   notify({
     title: "Create Invite",
     description: `Creating invite link for ${name}...`,
     promise: inviteRequest
       .post({
         name,
         email,
         role,
         auto_groups: groupIds,
-        expires_in: parseInt(expiresIn || "3") * 24 * 60 * 60, // Days to seconds
+        expires_in: expiresDays * 24 * 60 * 60, // Days to seconds
       })
       .then((invite) => {
         mutate("/users?service_user=false");
         onInviteCreated && onInviteCreated(invite);
       }),
       loadingMessage: "Creating invite...",
     });
   };

   const isDisabled = useMemo(() => {
-  return name.length === 0 || !isValidEmail;
-}, [name, isValidEmail]);
+  const expiresDays = Number.parseInt(expiresIn, 10);
+  const invalidExpires =
+    !isCloud &&
+    mode === "invite" &&
+    (!Number.isFinite(expiresDays) || expiresDays < 1);
+  return name.length === 0 || !isValidEmail || invalidExpires;
+}, [name, isValidEmail, expiresIn, isCloud, mode]);
🤖 Fix all issues with AI agents
In `@src/app/invite/page.tsx`:
- Line 66: The displayed special-character hint in the invite page is
inconsistent with the validator: the regex referenced by hasSpecialChar
(/[!@#$%^&*(),.?":{}|<>]/.test(password)) accepts many more characters than the
UI hint describes; fix by either updating the regex used for validation
(hasSpecialChar) to only include the documented set (!@#$%^&*) or updating the
UI hint text to list all allowed characters (e.g., include , . " : { } | < > and
others present in the regex) so the password validation and the visible
requirement string in the page component remain identical.

In `@src/modules/users/UserInvitesTable.tsx`:
- Around line 410-422: The default sorting state uses a non-existent column id
"is_current" which prevents initial sort from applying; update the
useLocalStorage initial value in the sorting state (the call to
useLocalStorage<SortingState> that sets "netbird-table-sort-invites" + path) to
reference a valid column id from InvitesTableColumns (e.g., replace "is_current"
with an existing id such as "name" or the actual status column id defined in
InvitesTableColumns) so the sorting and persisted state are consistent with the
table's column definitions.
🧹 Nitpick comments (3)
src/app/invite/page.tsx (1)

80-80: Consider using a typed error instead of any.

Using any bypasses TypeScript's type checking. The ApiError interface from the unauthenticated API could be used here.

♻️ Suggested improvement
+import { ApiError } from "@/interfaces/Instance";
+
 // ...
 
-    } catch (err: any) {
-      setError(err.message || "Failed to accept invite");
+    } catch (err) {
+      const apiError = err as ApiError;
+      setError(apiError.message || "Failed to accept invite");
     } finally {
src/utils/unauthenticatedApi.ts (1)

57-69: Consider URL-encoding the token parameter for defensive programming.

While the invite tokens currently work unencoded in both path and query parameters throughout the codebase, URL-encoding is a best practice if tokens could contain special characters. The codebase already uses encodeURIComponent() in similar contexts elsewhere (e.g., SSHCredentialsModal.tsx, api.tsx), so applying it here would align with existing patterns.

♻️ Suggested refactoring
 export async function fetchInviteInfo(token: string): Promise<UserInviteInfo> {
-  return unauthenticatedRequest<UserInviteInfo>("GET", `/users/invites/${token}`);
+  return unauthenticatedRequest<UserInviteInfo>("GET", `/users/invites/${encodeURIComponent(token)}`);
 }

 export async function acceptInvite(
   token: string,
   password: string,
 ): Promise<UserInviteAcceptResponse> {
   return unauthenticatedRequest<UserInviteAcceptResponse>(
     "POST",
-    `/users/invites/${token}/accept`,
+    `/users/invites/${encodeURIComponent(token)}/accept`,
     { password },
   );
 }
src/interfaces/User.ts (1)

20-63: Prefer Role enum for invite role fields.

Using string here bypasses the existing Role enum and forces downstream casts. Aligning these to Role improves type safety without changing runtime behavior.

♻️ Suggested type alignment
 export interface UserInviteCreateRequest {
   email: string;
   name: string;
-  role: string;
+  role: Role;
   auto_groups: string[];
   expires_in?: number;
 }

 export interface UserInviteCreateResponse {
   id: string;
   email: string;
   name: string;
-  role: string;
+  role: Role;
   auto_groups: string[];
   status: string;
   invite_link: string;
   invite_expires_at: string;
 }

 export interface UserInviteListItem {
   id: string;
   email: string;
   name: string;
-  role: string;
+  role: Role;
   auto_groups: string[];
   expires_at: string;
   created_at: string;
   expired: boolean;
 }

Comment thread src/app/invite/page.tsx
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, read the file to verify the exact content at lines 66 and 224
echo "=== Reading src/app/invite/page.tsx ==="
wc -l src/app/invite/page.tsx

Repository: netbirdio/dashboard

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Read lines around line 66
echo "=== Lines 60-75 (around line 66) ==="
sed -n '60,75p' src/app/invite/page.tsx | cat -n

Repository: netbirdio/dashboard

Length of output: 849


🏁 Script executed:

#!/bin/bash
# Read lines around line 224
echo "=== Lines 218-230 (around line 224) ==="
sed -n '218,230p' src/app/invite/page.tsx | cat -n

Repository: netbirdio/dashboard

Length of output: 782


🏁 Script executed:

#!/bin/bash
# Search for password validation in backend
echo "=== Searching for password validation patterns ==="
rg -n "password.*valid|hasSpecialChar|special.*char|minLength" --type=ts --type=tsx -C2

Repository: netbirdio/dashboard

Length of output: 158


🏁 Script executed:

#!/bin/bash
# Search more broadly for validation rules
echo "=== Searching for validation schemas or requirements ==="
rg -n "schema|zod|validation|password" --type=ts --type=tsx | grep -i "password\|special\|schema" | head -40

Repository: netbirdio/dashboard

Length of output: 164


🏁 Script executed:

#!/bin/bash
# Search for API calls related to password validation
echo "=== Searching for password API endpoints ==="
rg -n "password" --type=ts -l | head -20

Repository: netbirdio/dashboard

Length of output: 727


🏁 Script executed:

#!/bin/bash
# Search in common backend directories
echo "=== Searching backend directory structure ==="
fd -t f -e ts -e js "password" --max-depth 3 2>/dev/null | grep -E "api|server|backend" | head -20

Repository: netbirdio/dashboard

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Look for API calls in the invite page itself
echo "=== API call in invite page ==="
sed -n '70,120p' src/app/invite/page.tsx | cat -n

Repository: netbirdio/dashboard

Length of output: 1873


🏁 Script executed:

#!/bin/bash
# Search for password-related API or validation constants
echo "=== Searching for password requirements or API ==="
rg -n "password\|validation\|requirements" src/ -i | grep -i "password\|special" | head -30

Repository: netbirdio/dashboard

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Find where acceptInvite is imported from
echo "=== Searching for acceptInvite import ==="
rg -n "acceptInvite|import.*acceptInvite" src/app/invite/page.tsx -B2 -A2

Repository: netbirdio/dashboard

Length of output: 447


🏁 Script executed:

#!/bin/bash
# Find acceptInvite function definition
echo "=== Finding acceptInvite function ==="
rg -n "acceptInvite|function acceptInvite|const acceptInvite" src/ -A10 | head -50

Repository: netbirdio/dashboard

Length of output: 1890


🏁 Script executed:

#!/bin/bash
# Check the unauthenticatedApi file since it was mentioned
echo "=== Checking unauthenticatedApi.ts ==="
cat -n src/utils/unauthenticatedApi.ts

Repository: netbirdio/dashboard

Length of output: 2374


Align special character validation hint with regex.

The regex at line 66 accepts 16 special characters (!@#$%^&*(),.?":{}|<>), but the UI hint at line 224 only documents 8 of them (!@#$%^&*). Users can successfully use . or , but won't know these are valid, creating confusion.

Either expand the hint to show all accepted characters, or reduce the regex to match the documented set:

Suggested fix

Option 1 - Simplify regex to match hint:

-  const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
+  const hasSpecialChar = /[!@#$%^&*()]/.test(password);

Option 2 - Expand hint to show all accepted characters:

-                  <PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
+                  <PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*(),.?\":{}|<>)" />
📝 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 hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const hasSpecialChar = /[!@#$%^&*()]/.test(password);
🤖 Prompt for AI Agents
In `@src/app/invite/page.tsx` at line 66, The displayed special-character hint in
the invite page is inconsistent with the validator: the regex referenced by
hasSpecialChar (/[!@#$%^&*(),.?":{}|<>]/.test(password)) accepts many more
characters than the UI hint describes; fix by either updating the regex used for
validation (hasSpecialChar) to only include the documented set (!@#$%^&*) or
updating the UI hint text to list all allowed characters (e.g., include , . " :
{ } | < > and others present in the regex) so the password validation and the
visible requirement string in the page component remain identical.

Comment on lines +410 to +422
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort-invites" + path,
[
{
id: "is_current",
desc: true,
},
{
id: "name",
desc: true,
},
],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Default sorting references a non-existent column.

Line 415 uses is_current, which isn’t defined in InvitesTableColumns, so the initial sort will be ignored and stored state may be inconsistent. Use an existing column id instead.

🐛 Suggested fix
 const [sorting, setSorting] = useLocalStorage<SortingState>(
   "netbird-table-sort-invites" + path,
   [
-    {
-      id: "is_current",
-      desc: true,
-    },
     {
+      id: "expires_at",
+      desc: true,
+    },
+    {
       id: "name",
       desc: true,
     },
   ],
 );
🤖 Prompt for AI Agents
In `@src/modules/users/UserInvitesTable.tsx` around lines 410 - 422, The default
sorting state uses a non-existent column id "is_current" which prevents initial
sort from applying; update the useLocalStorage initial value in the sorting
state (the call to useLocalStorage<SortingState> that sets
"netbird-table-sort-invites" + path) to reference a valid column id from
InvitesTableColumns (e.g., replace "is_current" with an existing id such as
"name" or the actual status column id defined in InvitesTableColumns) so the
sorting and persisted state are consistent with the table's column definitions.

* Add version component

* Add version update

* Add version update

* Show version only for self-hosted

* Add version to dashboard on CI/CD
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/VersionInfo.tsx`:
- Around line 20-35: The compareVersions function fails to handle version
strings with a leading prefix like "v1.2.3"; normalize both inputs at the start
of compareVersions by trimming whitespace and stripping any leading non-numeric
characters (e.g., remove a leading "v" or other prefix via a regex) before
splitting and parsing so the major/minor/patch components parse correctly;
update compareVersions to sanitize current and latest (e.g., current =
current.trim().replace(/^[^\d]*/, "") and same for latest) then proceed with the
existing split/parse/compare logic.
♻️ Duplicate comments (1)
src/app/invite/page.tsx (1)

74-74: Special character validation mismatch between regex and UI hint.

The regex accepts 16 special characters (!@#$%^&*(),.?":{}|<>), but the UI hint at line 263 only shows 8 (!@#$%^&*). This creates user confusion as valid characters aren't documented.

Comment on lines +20 to +35
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;

const currentParts = current.split(".").map((p) => parseInt(p, 10) || 0);
const latestParts = latest.split(".").map((p) => parseInt(p, 10) || 0);

for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize version strings before comparison.

If versions include a leading prefix (e.g., “v1.2.3”), the major component parses to 0 and a major bump (v1 → v2) can be missed.

🛠️ Suggested fix
-  const currentParts = current.split(".").map((p) => parseInt(p, 10) || 0);
-  const latestParts = latest.split(".").map((p) => parseInt(p, 10) || 0);
+  const normalize = (v: string) => v.trim().replace(/^[^\d]*/, "");
+  const currentParts = normalize(current).split(".").map((p) => parseInt(p, 10) || 0);
+  const latestParts = normalize(latest).split(".").map((p) => parseInt(p, 10) || 0);
📝 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
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;
const currentParts = current.split(".").map((p) => parseInt(p, 10) || 0);
const latestParts = latest.split(".").map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;
const normalize = (v: string) => v.trim().replace(/^[^\d]*/, "");
const currentParts = normalize(current).split(".").map((p) => parseInt(p, 10) || 0);
const latestParts = normalize(latest).split(".").map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
🤖 Prompt for AI Agents
In `@src/components/VersionInfo.tsx` around lines 20 - 35, The compareVersions
function fails to handle version strings with a leading prefix like "v1.2.3";
normalize both inputs at the start of compareVersions by trimming whitespace and
stripping any leading non-numeric characters (e.g., remove a leading "v" or
other prefix via a regex) before splitting and parsing so the major/minor/patch
components parse correctly; update compareVersions to sanitize current and
latest (e.g., current = current.trim().replace(/^[^\d]*/, "") and same for
latest) then proceed with the existing split/parse/compare logic.

@braginini braginini merged commit f67f39b into main Jan 25, 2026
4 checks passed
@braginini braginini deleted the feature/local-user-invites branch January 25, 2026 20:40
@coderabbitai coderabbitai Bot mentioned this pull request Feb 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants