Skip to content
11 changes: 11 additions & 0 deletions .github/workflows/build_and_push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'

- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi

- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
Expand Down
2 changes: 2 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const nextConfig = {
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
},
};

Expand Down
8 changes: 8 additions & 0 deletions src/app/invite/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";

export const metadata: Metadata = {
title: `Accept Invite - ${globalMetaTitle}`,
};
export default BlankLayout;
321 changes: 321 additions & 0 deletions src/app/invite/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
"use client";

import Button from "@components/Button";
import { Input } from "@components/Input";
import Paragraph from "@components/Paragraph";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
import {
AlertCircle,
CheckCircle2,
Clock,
KeyRound,
Mail,
User2,
} from "lucide-react";
import dayjs from "dayjs";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { UserInviteInfo } from "@/interfaces/User";

export default function InviteAcceptPage() {
return (
<Suspense fallback={<FullScreenLoading />}>
<InviteAcceptContent />
</Suspense>
);
}

function InviteAcceptContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams?.get("token");

const [loading, setLoading] = useState(true);
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRateLimited, setIsRateLimited] = useState(false);

const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);

useEffect(() => {
if (!token) {
setError("No invite token provided");
setLoading(false);
return;
}

fetchInviteInfo(token)
.then((info) => {
setInviteInfo(info);
setLoading(false);
})
.catch((err) => {
if (err.code === 429) {
setError("Too many attempts. Please wait a moment and try again.");
setIsRateLimited(true);
} else {
setError(err.message || "Invalid or expired invite link");
setIsRateLimited(false);
}
setLoading(false);
});
}, [token]);

const passwordsMatch = password === confirmPassword;
const hasMinLength = password.length >= 8;
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.

const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
const canSubmit = passwordValid && passwordsMatch && !submitting;

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit || !token) return;

setSubmitting(true);
setError(null);

try {
await acceptInvite(token, password);
setSuccess(true);
} catch (err: any) {
setError(err.message || "Failed to accept invite");
} finally {
setSubmitting(false);
}
};

const isExpired = useMemo(() => {
if (!inviteInfo) return false;
return new Date(inviteInfo.expires_at) < new Date();
}, [inviteInfo]);

if (loading) {
return <FullScreenLoading />;
}

if (error && !inviteInfo) {
if (isRateLimited) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<Clock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Too Many Requests
</h1>
<Paragraph className="text-nb-gray-400 text-base">
You&apos;ve made too many requests. Please wait a moment and try
again.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => window.location.reload()}
>
Try Again
</Button>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invalid Invite
</h1>
<Paragraph className="text-nb-gray-400 text-base">
This invite link is invalid or has expired. Please contact your
administrator to receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}

if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Account Created!
</h1>
<Paragraph className="text-nb-gray-400">
Your account has been created successfully. You can now log in with
your email and password.
</Paragraph>
<Button
variant="primary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}

if (isExpired || !inviteInfo?.valid) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invite Expired
</h1>
<Paragraph className="text-nb-gray-400">
This invite link has expired. Please contact your administrator to
receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full">
<div className="mb-8 flex justify-center">
<NetBirdIcon size={48} />
</div>

<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-white mb-2">
Welcome to NetBird
</h1>
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
You&apos;ve been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
</p>
</div>

<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
<User2 className="w-5 h-5 text-nb-gray-400" />
</div>
<div>
<div className="text-white font-medium">{inviteInfo.name}</div>
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
<Mail className="w-3 h-3" />
{inviteInfo.email}
</div>
</div>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{password && (
<div className="mt-2 space-y-1">
<PasswordRule met={hasMinLength} text="At least 8 characters" />
<PasswordRule met={hasUppercase} text="One uppercase letter" />
<PasswordRule met={hasLowercase} text="One lowercase letter" />
<PasswordRule met={hasNumber} text="One number" />
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
</div>
)}
</div>

<div>
<Input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500 mt-1">
Passwords do not match
</p>
)}
</div>

{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
<p className="text-sm text-red-500">{error}</p>
</div>
)}

<Button
type="submit"
variant="primary"
className="w-full"
disabled={!canSubmit}
>
{submitting ? "Creating Account..." : "Create Account"}
</Button>
</form>
</div>

<p className="text-center text-xs text-nb-gray-500">
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
</p>
</div>
</div>
);
}

function PasswordRule({ met, text }: { met: boolean; text: string }) {
return (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
)}
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
</div>
);
}
4 changes: 3 additions & 1 deletion src/auth/OIDCProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ export default function OIDCProvider({ children }: Props) {
// We bypass authentication for pages that do not require auth.
// E.g., when we just want to show installation steps for public.
// Or the instance setup wizard for first-time setup.
if (path === "/install" || path === "/setup") return children;
// Or the invite acceptance page for new users.
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
return children;

return mounted && providerConfig ? (
<OidcProvider
Expand Down
Loading