-
Notifications
You must be signed in to change notification settings - Fork 137
Local user invites #539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,354
−38
Merged
Local user invites #539
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
af76814
Add local user invites
braginini 304f16a
Fix modal width when generating invite link
braginini 42cc562
Fix SegmentedTabs of the user invite modal
braginini 4b2dd97
Add invite accept window
braginini 8cbb7cd
Improve accept invite modal
braginini 638254a
Add the invites view (draft)
braginini 8504198
Fix invites table
braginini c16c09e
Add Add User to the invites view
braginini 9317de7
Add expires in
braginini 5e8adbf
Handle new activity events
braginini 5a926ed
Fix invalid invite
braginini 1b14757
Add message when exceeding the limits
braginini c686939
Show Versions (#540)
braginini File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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'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'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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 130
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 849
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 782
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 158
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 164
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 727
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 108
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 1873
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 113
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 447
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 1890
🏁 Script executed:
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:
Option 2 - Expand hint to show all accepted characters:
📝 Committable suggestion
🤖 Prompt for AI Agents