diff --git a/.gitignore b/.gitignore
index 700963cc..df9179a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,5 @@ dynamic/
*.mmdb
scratch/
tsconfig.json
-hydrateSaas.ts
\ No newline at end of file
+hydrateSaas.ts
+CLAUDE.md
\ No newline at end of file
diff --git a/messages/en-US.json b/messages/en-US.json
index 12e4f63f..c799a0cb 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -257,6 +257,8 @@
"accessRolesSearch": "Search roles...",
"accessRolesAdd": "Add Role",
"accessRoleDelete": "Delete Role",
+ "accessApprovalsManage": "Manage Approvals",
+ "accessApprovalsDescription": "Manage approval requests in the organization",
"description": "Description",
"inviteTitle": "Open Invitations",
"inviteDescription": "Manage invitations for other users to join the organization",
@@ -450,6 +452,18 @@
"selectDuration": "Select duration",
"selectResource": "Select Resource",
"filterByResource": "Filter By Resource",
+ "selectApprovalState": "Select Approval State",
+ "filterByApprovalState": "Filter By Approval State",
+ "approvalListEmpty": "No approvals",
+ "approvalState": "Approval State",
+ "approve": "Approve",
+ "approved": "Approved",
+ "denied": "Denied",
+ "deniedApproval": "Denied Approval",
+ "all": "All",
+ "deny": "Deny",
+ "viewDetails": "View Details",
+ "requestingNewDeviceApproval": "requested a new device",
"resetFilters": "Reset Filters",
"totalBlocked": "Requests Blocked By Pangolin",
"totalRequests": "Total Requests",
@@ -729,16 +743,28 @@
"countries": "Countries",
"accessRoleCreate": "Create Role",
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
+ "accessRoleEdit": "Edit Role",
+ "accessRoleEditDescription": "Edit role information.",
"accessRoleCreateSubmit": "Create Role",
"accessRoleCreated": "Role created",
"accessRoleCreatedDescription": "The role has been successfully created.",
"accessRoleErrorCreate": "Failed to create role",
"accessRoleErrorCreateDescription": "An error occurred while creating the role.",
+ "accessRoleUpdateSubmit": "Update Role",
+ "accessRoleUpdated": "Role updated",
+ "accessRoleUpdatedDescription": "The role has been successfully updated.",
+ "accessApprovalUpdated": "Approval processed",
+ "accessApprovalApprovedDescription": "Set Approval Request decision to approved.",
+ "accessApprovalDeniedDescription": "Set Approval Request decision to denied.",
+ "accessRoleErrorUpdate": "Failed to update role",
+ "accessRoleErrorUpdateDescription": "An error occurred while updating the role.",
+ "accessApprovalErrorUpdate": "Failed to process approval",
+ "accessApprovalErrorUpdateDescription": "An error occurred while processing the approval.",
"accessRoleErrorNewRequired": "New role is required",
"accessRoleErrorRemove": "Failed to remove role",
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
"accessRoleName": "Role Name",
- "accessRoleQuestionRemove": "You're about to delete the {name} role. You cannot undo this action.",
+ "accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.",
"accessRoleRemove": "Remove Role",
"accessRoleRemoveDescription": "Remove a role from the organization",
"accessRoleRemoveSubmit": "Remove Role",
@@ -874,7 +900,7 @@
"inviteAlready": "Looks like you've been invited!",
"inviteAlreadyDescription": "To accept the invite, you must log in or create an account.",
"signupQuestion": "Already have an account?",
- "login": "Log in",
+ "login": "Log In",
"resourceNotFound": "Resource Not Found",
"resourceNotFoundDescription": "The resource you're trying to access does not exist.",
"pincodeRequirementsLength": "PIN must be exactly 6 digits",
@@ -954,13 +980,13 @@
"passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.",
"changePasswordNow": "Change Password Now",
"pincodeAuth": "Authenticator Code",
- "pincodeSubmit2": "Submit Code",
+ "pincodeSubmit2": "Submit code",
"passwordResetSubmit": "Request Reset",
"passwordResetAlreadyHaveCode": "Enter Code",
"passwordResetSmtpRequired": "Please contact your administrator",
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
"passwordBack": "Back to Password",
- "loginBack": "Go back to log in",
+ "loginBack": "Go back to main login page",
"signup": "Sign up",
"loginStart": "Log in to get started",
"idpOidcTokenValidating": "Validating OIDC token",
@@ -1138,14 +1164,14 @@
"searchProgress": "Search...",
"create": "Create",
"orgs": "Organizations",
- "loginError": "An error occurred while logging in",
+ "loginError": "An unexpected error occurred. Please try again.",
"loginRequiredForDevice": "Login is required for your device.",
"passwordForgot": "Forgot your password?",
"otpAuth": "Two-Factor Authentication",
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with",
- "otpAuthBack": "Back to Log In",
+ "otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application",
"navbarDocsLink": "Documentation",
@@ -1193,6 +1219,7 @@
"sidebarOverview": "Overview",
"sidebarHome": "Home",
"sidebarSites": "Sites",
+ "sidebarApprovals": "Approval Requests",
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
@@ -1209,7 +1236,7 @@
"sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License",
"sidebarClients": "Clients",
- "sidebarUserDevices": "Users",
+ "sidebarUserDevices": "User Devices",
"sidebarMachineClients": "Machines",
"sidebarDomains": "Domains",
"sidebarGeneral": "Manage",
@@ -1308,6 +1335,7 @@
"refreshError": "Failed to refresh data",
"verified": "Verified",
"pending": "Pending",
+ "pendingApproval": "Pending Approval",
"sidebarBilling": "Billing",
"billing": "Billing",
"orgBillingDescription": "Manage billing information and subscriptions",
@@ -1424,7 +1452,7 @@
"securityKeyRemoveSuccess": "Security key removed successfully",
"securityKeyRemoveError": "Failed to remove security key",
"securityKeyLoadError": "Failed to load security keys",
- "securityKeyLogin": "Continue with security key",
+ "securityKeyLogin": "Use Security Key",
"securityKeyAuthError": "Failed to authenticate with security key",
"securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.",
"registering": "Registering...",
@@ -1551,6 +1579,8 @@
"IntervalSeconds": "Healthy Interval",
"timeoutSeconds": "Timeout (sec)",
"timeIsInSeconds": "Time is in seconds",
+ "requireDeviceApproval": "Require Device Approvals",
+ "requireDeviceApprovalDescription": "Users with this role need their devices approved by an admin before they can access resources",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -1880,7 +1910,7 @@
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
- "orgAuthSignInToOrg": "Use organization's identity provider",
+ "orgAuthSignInToOrg": "Sign in to an organization",
"orgAuthSelectOrgTitle": "Organization Sign In",
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
"orgAuthOrgIdPlaceholder": "your-organization",
@@ -2236,6 +2266,8 @@
"deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)",
"deviceCodeInvalidOrExpired": "Invalid or expired code",
"deviceCodeVerifyFailed": "Failed to verify device code",
+ "deviceCodeValidating": "Validating device code...",
+ "deviceCodeVerifying": "Verifying device authorization...",
"signedInAs": "Signed in as",
"deviceCodeEnterPrompt": "Enter the code displayed on the device",
"continue": "Continue",
@@ -2310,6 +2342,7 @@
"identifier": "Identifier",
"deviceLoginUseDifferentAccount": "Not you? Use a different account.",
"deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.",
+ "loginSelectAuthenticationMethod": "Select an authentication method to continue.",
"noData": "No Data",
"machineClients": "Machine Clients",
"install": "Install",
@@ -2424,5 +2457,30 @@
"blockClientQuestion": "Are you sure you want to block this client?",
"blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.",
"blockClientConfirm": "Block Client",
- "active": "Active"
+ "active": "Active",
+ "usernameOrEmail": "Username or Email",
+ "selectYourOrganization": "Select your organization",
+ "signInTo": "Log in in to",
+ "signInWithPassword": "Continue with Password",
+ "noAuthMethodsAvailable": "No authentication methods available for this organization.",
+ "enterPassword": "Enter your password",
+ "enterMfaCode": "Enter the code from your authenticator app",
+ "securityKeyRequired": "Please use your security key to sign in.",
+ "needToUseAnotherAccount": "Need to use a different account?",
+ "loginLegalDisclaimer": "By clicking the buttons below, you acknowledge you have read, understand, and agree to the Terms of Service and Privacy Policy.",
+ "termsOfService": "Terms of Service",
+ "privacyPolicy": "Privacy Policy",
+ "userNotFoundWithUsername": "No user found with that username.",
+ "verify": "Verify",
+ "signIn": "Sign In",
+ "forgotPassword": "Forgot password?",
+ "orgSignInTip": "If you've logged in before, you can enter your username or email above to authenticate with your organization's identity provider instead. It's easier!",
+ "continueAnyway": "Continue anyway",
+ "dontShowAgain": "Don't show again",
+ "orgSignInNotice": "Did you know?",
+ "signupOrgNotice": "Trying to sign in?",
+ "signupOrgTip": "Are you trying to sign in through your organization's identity provider?",
+ "signupOrgLink": "Sign in or sign up with your organization instead",
+ "verifyEmailLogInWithDifferentAccount": "Use a Different Account",
+ "logIn": "Log In"
}
diff --git a/server/auth/actions.ts b/server/auth/actions.ts
index ea3ab6d1..094437f4 100644
--- a/server/auth/actions.ts
+++ b/server/auth/actions.ts
@@ -129,7 +129,9 @@ export enum ActionsEnum {
getBlueprint = "getBlueprint",
applyBlueprint = "applyBlueprint",
viewLogs = "viewLogs",
- exportLogs = "exportLogs"
+ exportLogs = "exportLogs",
+ listApprovals = "listApprovals",
+ updateApprovals = "updateApprovals"
}
export async function checkUserActionPermission(
diff --git a/server/db/ios_models.json b/server/db/ios_models.json
new file mode 100644
index 00000000..99fcbea1
--- /dev/null
+++ b/server/db/ios_models.json
@@ -0,0 +1,150 @@
+{
+ "iPad1,1": "iPad",
+ "iPad2,1": "iPad 2",
+ "iPad2,2": "iPad 2",
+ "iPad2,3": "iPad 2",
+ "iPad2,4": "iPad 2",
+ "iPad3,1": "iPad 3rd Gen",
+ "iPad3,3": "iPad 3rd Gen",
+ "iPad3,2": "iPad 3rd Gen",
+ "iPad3,4": "iPad 4th Gen",
+ "iPad3,5": "iPad 4th Gen",
+ "iPad3,6": "iPad 4th Gen",
+ "iPad6,11": "iPad 9.7 5th Gen",
+ "iPad6,12": "iPad 9.7 5th Gen",
+ "iPad7,5": "iPad 9.7 6th Gen",
+ "iPad7,6": "iPad 9.7 6th Gen",
+ "iPad7,11": "iPad 10.2 7th Gen",
+ "iPad7,12": "iPad 10.2 7th Gen",
+ "iPad11,6": "iPad 10.2 8th Gen",
+ "iPad11,7": "iPad 10.2 8th Gen",
+ "iPad12,1": "iPad 10.2 9th Gen",
+ "iPad12,2": "iPad 10.2 9th Gen",
+ "iPad13,18": "iPad 10.9 10th Gen",
+ "iPad13,19": "iPad 10.9 10th Gen",
+ "iPad4,1": "iPad Air",
+ "iPad4,2": "iPad Air",
+ "iPad4,3": "iPad Air",
+ "iPad5,3": "iPad Air 2",
+ "iPad5,4": "iPad Air 2",
+ "iPad11,3": "iPad Air 3rd Gen",
+ "iPad11,4": "iPad Air 3rd Gen",
+ "iPad13,1": "iPad Air 4th Gen",
+ "iPad13,2": "iPad Air 4th Gen",
+ "iPad13,16": "iPad Air 5th Gen",
+ "iPad13,17": "iPad Air 5th Gen",
+ "iPad14,8": "iPad Air M2 11",
+ "iPad14,9": "iPad Air M2 11",
+ "iPad14,10": "iPad Air M2 13",
+ "iPad14,11": "iPad Air M2 13",
+ "iPad2,5": "iPad mini",
+ "iPad2,6": "iPad mini",
+ "iPad2,7": "iPad mini",
+ "iPad4,4": "iPad mini 2",
+ "iPad4,5": "iPad mini 2",
+ "iPad4,6": "iPad mini 2",
+ "iPad4,7": "iPad mini 3",
+ "iPad4,8": "iPad mini 3",
+ "iPad4,9": "iPad mini 3",
+ "iPad5,1": "iPad mini 4",
+ "iPad5,2": "iPad mini 4",
+ "iPad11,1": "iPad mini 5th Gen",
+ "iPad11,2": "iPad mini 5th Gen",
+ "iPad14,1": "iPad mini 6th Gen",
+ "iPad14,2": "iPad mini 6th Gen",
+ "iPad6,7": "iPad Pro 12.9",
+ "iPad6,8": "iPad Pro 12.9",
+ "iPad6,3": "iPad Pro 9.7",
+ "iPad6,4": "iPad Pro 9.7",
+ "iPad7,3": "iPad Pro 10.5",
+ "iPad7,4": "iPad Pro 10.5",
+ "iPad7,1": "iPad Pro 12.9",
+ "iPad7,2": "iPad Pro 12.9",
+ "iPad8,1": "iPad Pro 11",
+ "iPad8,2": "iPad Pro 11",
+ "iPad8,3": "iPad Pro 11",
+ "iPad8,4": "iPad Pro 11",
+ "iPad8,5": "iPad Pro 12.9",
+ "iPad8,6": "iPad Pro 12.9",
+ "iPad8,7": "iPad Pro 12.9",
+ "iPad8,8": "iPad Pro 12.9",
+ "iPad8,9": "iPad Pro 11",
+ "iPad8,10": "iPad Pro 11",
+ "iPad8,11": "iPad Pro 12.9",
+ "iPad8,12": "iPad Pro 12.9",
+ "iPad13,4": "iPad Pro 11",
+ "iPad13,5": "iPad Pro 11",
+ "iPad13,6": "iPad Pro 11",
+ "iPad13,7": "iPad Pro 11",
+ "iPad13,8": "iPad Pro 12.9",
+ "iPad13,9": "iPad Pro 12.9",
+ "iPad13,10": "iPad Pro 12.9",
+ "iPad13,11": "iPad Pro 12.9",
+ "iPad14,3": "iPad Pro 11",
+ "iPad14,4": "iPad Pro 11",
+ "iPad14,5": "iPad Pro 12.9",
+ "iPad14,6": "iPad Pro 12.9",
+ "iPad16,3": "iPad Pro M4 11",
+ "iPad16,4": "iPad Pro M4 11",
+ "iPad16,5": "iPad Pro M4 13",
+ "iPad16,6": "iPad Pro M4 13",
+ "iPhone1,1": "iPhone",
+ "iPhone1,2": "iPhone 3G",
+ "iPhone2,1": "iPhone 3GS",
+ "iPhone3,1": "iPhone 4",
+ "iPhone3,2": "iPhone 4",
+ "iPhone3,3": "iPhone 4",
+ "iPhone4,1": "iPhone 4S",
+ "iPhone5,1": "iPhone 5",
+ "iPhone5,2": "iPhone 5",
+ "iPhone5,3": "iPhone 5c",
+ "iPhone5,4": "iPhone 5c",
+ "iPhone6,1": "iPhone 5s",
+ "iPhone6,2": "iPhone 5s",
+ "iPhone7,2": "iPhone 6",
+ "iPhone7,1": "iPhone 6 Plus",
+ "iPhone8,1": "iPhone 6s",
+ "iPhone8,2": "iPhone 6s Plus",
+ "iPhone8,4": "iPhone SE",
+ "iPhone9,1": "iPhone 7",
+ "iPhone9,3": "iPhone 7",
+ "iPhone9,2": "iPhone 7 Plus",
+ "iPhone9,4": "iPhone 7 Plus",
+ "iPhone10,1": "iPhone 8",
+ "iPhone10,4": "iPhone 8",
+ "iPhone10,2": "iPhone 8 Plus",
+ "iPhone10,5": "iPhone 8 Plus",
+ "iPhone10,3": "iPhone X",
+ "iPhone10,6": "iPhone X",
+ "iPhone11,2": "iPhone Xs",
+ "iPhone11,6": "iPhone Xs Max",
+ "iPhone11,8": "iPhone XR",
+ "iPhone12,1": "iPhone 11",
+ "iPhone12,3": "iPhone 11 Pro",
+ "iPhone12,5": "iPhone 11 Pro Max",
+ "iPhone12,8": "iPhone SE",
+ "iPhone13,1": "iPhone 12 mini",
+ "iPhone13,2": "iPhone 12",
+ "iPhone13,3": "iPhone 12 Pro",
+ "iPhone13,4": "iPhone 12 Pro Max",
+ "iPhone14,4": "iPhone 13 mini",
+ "iPhone14,5": "iPhone 13",
+ "iPhone14,2": "iPhone 13 Pro",
+ "iPhone14,3": "iPhone 13 Pro Max",
+ "iPhone14,6": "iPhone SE",
+ "iPhone14,7": "iPhone 14",
+ "iPhone14,8": "iPhone 14 Plus",
+ "iPhone15,2": "iPhone 14 Pro",
+ "iPhone15,3": "iPhone 14 Pro Max",
+ "iPhone15,4": "iPhone 15",
+ "iPhone15,5": "iPhone 15 Plus",
+ "iPhone16,1": "iPhone 15 Pro",
+ "iPhone16,2": "iPhone 15 Pro Max",
+ "iPod1,1": "iPod touch Original",
+ "iPod2,1": "iPod touch 2nd",
+ "iPod3,1": "iPod touch 3rd Gen",
+ "iPod4,1": "iPod touch 4th",
+ "iPod5,1": "iPod touch 5th",
+ "iPod7,1": "iPod touch 6th Gen",
+ "iPod9,1": "iPod touch 7th Gen"
+}
\ No newline at end of file
diff --git a/server/db/mac_models.json b/server/db/mac_models.json
new file mode 100644
index 00000000..db473f3a
--- /dev/null
+++ b/server/db/mac_models.json
@@ -0,0 +1,201 @@
+{
+ "PowerMac4,4": "eMac",
+ "PowerMac6,4": "eMac",
+ "PowerBook2,1": "iBook",
+ "PowerBook2,2": "iBook",
+ "PowerBook4,1": "iBook",
+ "PowerBook4,2": "iBook",
+ "PowerBook4,3": "iBook",
+ "PowerBook6,3": "iBook",
+ "PowerBook6,5": "iBook",
+ "PowerBook6,7": "iBook",
+ "iMac,1": "iMac",
+ "PowerMac2,1": "iMac",
+ "PowerMac2,2": "iMac",
+ "PowerMac4,1": "iMac",
+ "PowerMac4,2": "iMac",
+ "PowerMac4,5": "iMac",
+ "PowerMac6,1": "iMac",
+ "PowerMac6,3*": "iMac",
+ "PowerMac6,3": "iMac",
+ "PowerMac8,1": "iMac",
+ "PowerMac8,2": "iMac",
+ "PowerMac12,1": "iMac",
+ "iMac4,1": "iMac",
+ "iMac4,2": "iMac",
+ "iMac5,2": "iMac",
+ "iMac5,1": "iMac",
+ "iMac6,1": "iMac",
+ "iMac7,1": "iMac",
+ "iMac8,1": "iMac",
+ "iMac9,1": "iMac",
+ "iMac10,1": "iMac",
+ "iMac11,1": "iMac",
+ "iMac11,2": "iMac",
+ "iMac11,3": "iMac",
+ "iMac12,1": "iMac",
+ "iMac12,2": "iMac",
+ "iMac13,1": "iMac",
+ "iMac13,2": "iMac",
+ "iMac14,1": "iMac",
+ "iMac14,3": "iMac",
+ "iMac14,2": "iMac",
+ "iMac14,4": "iMac",
+ "iMac15,1": "iMac",
+ "iMac16,1": "iMac",
+ "iMac16,2": "iMac",
+ "iMac17,1": "iMac",
+ "iMac18,1": "iMac",
+ "iMac18,2": "iMac",
+ "iMac18,3": "iMac",
+ "iMac19,2": "iMac",
+ "iMac19,1": "iMac",
+ "iMac20,1": "iMac",
+ "iMac20,2": "iMac",
+ "iMac21,2": "iMac",
+ "iMac21,1": "iMac",
+ "iMacPro1,1": "iMac Pro",
+ "PowerMac10,1": "Mac mini",
+ "PowerMac10,2": "Mac mini",
+ "Macmini1,1": "Mac mini",
+ "Macmini2,1": "Mac mini",
+ "Macmini3,1": "Mac mini",
+ "Macmini4,1": "Mac mini",
+ "Macmini5,1": "Mac mini",
+ "Macmini5,2": "Mac mini",
+ "Macmini5,3": "Mac mini",
+ "Macmini6,1": "Mac mini",
+ "Macmini6,2": "Mac mini",
+ "Macmini7,1": "Mac mini",
+ "Macmini8,1": "Mac mini",
+ "ADP3,2": "Mac mini",
+ "Macmini9,1": "Mac mini",
+ "Mac14,3": "Mac mini",
+ "Mac14,12": "Mac mini",
+ "MacPro1,1*": "Mac Pro",
+ "MacPro2,1": "Mac Pro",
+ "MacPro3,1": "Mac Pro",
+ "MacPro4,1": "Mac Pro",
+ "MacPro5,1": "Mac Pro",
+ "MacPro6,1": "Mac Pro",
+ "MacPro7,1": "Mac Pro",
+ "N/A*": "Power Macintosh",
+ "PowerMac1,1": "Power Macintosh",
+ "PowerMac3,1": "Power Macintosh",
+ "PowerMac3,3": "Power Macintosh",
+ "PowerMac3,4": "Power Macintosh",
+ "PowerMac3,5": "Power Macintosh",
+ "PowerMac3,6": "Power Macintosh",
+ "Mac13,1": "Mac Studio",
+ "Mac13,2": "Mac Studio",
+ "MacBook1,1": "MacBook",
+ "MacBook2,1": "MacBook",
+ "MacBook3,1": "MacBook",
+ "MacBook4,1": "MacBook",
+ "MacBook5,1": "MacBook",
+ "MacBook5,2": "MacBook",
+ "MacBook6,1": "MacBook",
+ "MacBook7,1": "MacBook",
+ "MacBook8,1": "MacBook",
+ "MacBook9,1": "MacBook",
+ "MacBook10,1": "MacBook",
+ "MacBookAir1,1": "MacBook Air",
+ "MacBookAir2,1": "MacBook Air",
+ "MacBookAir3,1": "MacBook Air",
+ "MacBookAir3,2": "MacBook Air",
+ "MacBookAir4,1": "MacBook Air",
+ "MacBookAir4,2": "MacBook Air",
+ "MacBookAir5,1": "MacBook Air",
+ "MacBookAir5,2": "MacBook Air",
+ "MacBookAir6,1": "MacBook Air",
+ "MacBookAir6,2": "MacBook Air",
+ "MacBookAir7,1": "MacBook Air",
+ "MacBookAir7,2": "MacBook Air",
+ "MacBookAir8,1": "MacBook Air",
+ "MacBookAir8,2": "MacBook Air",
+ "MacBookAir9,1": "MacBook Air",
+ "MacBookAir10,1": "MacBook Air",
+ "Mac14,2": "MacBook Air",
+ "MacBookPro1,1": "MacBook Pro",
+ "MacBookPro1,2": "MacBook Pro",
+ "MacBookPro2,2": "MacBook Pro",
+ "MacBookPro2,1": "MacBook Pro",
+ "MacBookPro3,1": "MacBook Pro",
+ "MacBookPro4,1": "MacBook Pro",
+ "MacBookPro5,1": "MacBook Pro",
+ "MacBookPro5,2": "MacBook Pro",
+ "MacBookPro5,5": "MacBook Pro",
+ "MacBookPro5,4": "MacBook Pro",
+ "MacBookPro5,3": "MacBook Pro",
+ "MacBookPro7,1": "MacBook Pro",
+ "MacBookPro6,2": "MacBook Pro",
+ "MacBookPro6,1": "MacBook Pro",
+ "MacBookPro8,1": "MacBook Pro",
+ "MacBookPro8,2": "MacBook Pro",
+ "MacBookPro8,3": "MacBook Pro",
+ "MacBookPro9,2": "MacBook Pro",
+ "MacBookPro9,1": "MacBook Pro",
+ "MacBookPro10,1": "MacBook Pro",
+ "MacBookPro10,2": "MacBook Pro",
+ "MacBookPro11,1": "MacBook Pro",
+ "MacBookPro11,2": "MacBook Pro",
+ "MacBookPro11,3": "MacBook Pro",
+ "MacBookPro12,1": "MacBook Pro",
+ "MacBookPro11,4": "MacBook Pro",
+ "MacBookPro11,5": "MacBook Pro",
+ "MacBookPro13,1": "MacBook Pro",
+ "MacBookPro13,2": "MacBook Pro",
+ "MacBookPro13,3": "MacBook Pro",
+ "MacBookPro14,1": "MacBook Pro",
+ "MacBookPro14,2": "MacBook Pro",
+ "MacBookPro14,3": "MacBook Pro",
+ "MacBookPro15,2": "MacBook Pro",
+ "MacBookPro15,1": "MacBook Pro",
+ "MacBookPro15,3": "MacBook Pro",
+ "MacBookPro15,4": "MacBook Pro",
+ "MacBookPro16,1": "MacBook Pro",
+ "MacBookPro16,3": "MacBook Pro",
+ "MacBookPro16,2": "MacBook Pro",
+ "MacBookPro16,4": "MacBook Pro",
+ "MacBookPro17,1": "MacBook Pro",
+ "MacBookPro18,3": "MacBook Pro",
+ "MacBookPro18,4": "MacBook Pro",
+ "MacBookPro18,1": "MacBook Pro",
+ "MacBookPro18,2": "MacBook Pro",
+ "Mac14,7": "MacBook Pro",
+ "Mac14,9": "MacBook Pro",
+ "Mac14,5": "MacBook Pro",
+ "Mac14,10": "MacBook Pro",
+ "Mac14,6": "MacBook Pro",
+ "PowerMac1,2": "Power Macintosh",
+ "PowerMac5,1": "Power Macintosh",
+ "PowerMac7,2": "Power Macintosh",
+ "PowerMac7,3": "Power Macintosh",
+ "PowerMac9,1": "Power Macintosh",
+ "PowerMac11,2": "Power Macintosh",
+ "PowerBook1,1": "PowerBook",
+ "PowerBook3,1": "PowerBook",
+ "PowerBook3,2": "PowerBook",
+ "PowerBook3,3": "PowerBook",
+ "PowerBook3,4": "PowerBook",
+ "PowerBook3,5": "PowerBook",
+ "PowerBook6,1": "PowerBook",
+ "PowerBook5,1": "PowerBook",
+ "PowerBook6,2": "PowerBook",
+ "PowerBook5,2": "PowerBook",
+ "PowerBook5,3": "PowerBook",
+ "PowerBook6,4": "PowerBook",
+ "PowerBook5,4": "PowerBook",
+ "PowerBook5,5": "PowerBook",
+ "PowerBook6,8": "PowerBook",
+ "PowerBook5,6": "PowerBook",
+ "PowerBook5,7": "PowerBook",
+ "PowerBook5,8": "PowerBook",
+ "PowerBook5,9": "PowerBook",
+ "RackMac1,1": "Xserve",
+ "RackMac1,2": "Xserve",
+ "RackMac3,1": "Xserve",
+ "Xserve1,1": "Xserve",
+ "Xserve2,1": "Xserve",
+ "Xserve3,1": "Xserve"
+}
\ No newline at end of file
diff --git a/server/db/names.ts b/server/db/names.ts
index 32b0a393..6f9e1230 100644
--- a/server/db/names.ts
+++ b/server/db/names.ts
@@ -16,6 +16,24 @@ if (!dev) {
}
export const names = JSON.parse(readFileSync(file, "utf-8"));
+// Load iOS and Mac model mappings
+let iosModelsFile: string;
+let macModelsFile: string;
+if (!dev) {
+ iosModelsFile = join(__DIRNAME, "ios_models.json");
+ macModelsFile = join(__DIRNAME, "mac_models.json");
+} else {
+ iosModelsFile = join("server/db/ios_models.json");
+ macModelsFile = join("server/db/mac_models.json");
+}
+
+const iosModels: Record = JSON.parse(
+ readFileSync(iosModelsFile, "utf-8")
+);
+const macModels: Record = JSON.parse(
+ readFileSync(macModelsFile, "utf-8")
+);
+
export async function getUniqueClientName(orgId: string): Promise {
let loops = 0;
while (true) {
@@ -159,3 +177,29 @@ export function generateName(): string {
// clean out any non-alphanumeric characters except for dashes
return name.replace(/[^a-z0-9-]/g, "");
}
+
+export function getMacDeviceName(macIdentifier?: string | null): string | null {
+ if (macIdentifier && macModels[macIdentifier]) {
+ return macModels[macIdentifier];
+ }
+ return null;
+}
+
+export function getIosDeviceName(iosIdentifier?: string | null): string | null {
+ if (iosIdentifier && iosModels[iosIdentifier]) {
+ return iosModels[iosIdentifier];
+ }
+ return null;
+}
+
+export function getUserDeviceName(
+ model: string | null,
+ fallBack: string | null
+): string {
+ return (
+ getMacDeviceName(model) ||
+ getIosDeviceName(model) ||
+ fallBack ||
+ "Unknown Device"
+ );
+}
diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts
index 1f30dbf5..3900f46a 100644
--- a/server/db/pg/schema/privateSchema.ts
+++ b/server/db/pg/schema/privateSchema.ts
@@ -10,7 +10,15 @@ import {
index
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
-import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
+import {
+ domains,
+ orgs,
+ targets,
+ users,
+ exitNodes,
+ sessions,
+ clients
+} from "./schema";
export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(),
@@ -289,6 +297,33 @@ export const accessAuditLog = pgTable(
]
);
+export const approvals = pgTable("approvals", {
+ approvalId: serial("approvalId").primaryKey(),
+ timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
+ orgId: varchar("orgId")
+ .references(() => orgs.orgId, {
+ onDelete: "cascade"
+ })
+ .notNull(),
+ clientId: integer("clientId").references(() => clients.clientId, {
+ onDelete: "cascade"
+ }), // clients reference user devices (in this case)
+ userId: varchar("userId")
+ .references(() => users.userId, {
+ // optionally tied to a user and in this case delete when the user deletes
+ onDelete: "cascade"
+ })
+ .notNull(),
+ decision: varchar("decision")
+ .$type<"approved" | "denied" | "pending">()
+ .default("pending")
+ .notNull(),
+ type: varchar("type")
+ .$type<"user_device" /*| 'proxy' // for later */>()
+ .notNull()
+});
+
+export type Approval = InferSelectModel;
export type Limit = InferSelectModel;
export type Account = InferSelectModel;
export type Certificate = InferSelectModel;
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index a0b1e3be..9fb1932c 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -365,7 +365,8 @@ export const roles = pgTable("roles", {
.notNull(),
isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(),
- description: varchar("description")
+ description: varchar("description"),
+ requireDeviceApproval: boolean("requireDeviceApproval").default(false)
});
export const roleActions = pgTable("roleActions", {
@@ -591,7 +592,8 @@ export const idp = pgTable("idp", {
type: varchar("type").notNull(),
defaultRoleMapping: varchar("defaultRoleMapping"),
defaultOrgMapping: varchar("defaultOrgMapping"),
- autoProvision: boolean("autoProvision").notNull().default(false)
+ autoProvision: boolean("autoProvision").notNull().default(false),
+ tags: text("tags")
});
export const idpOidcConfig = pgTable("idpOidcConfig", {
@@ -690,7 +692,10 @@ export const clients = pgTable("clients", {
lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections"),
archived: boolean("archived").notNull().default(false),
- blocked: boolean("blocked").notNull().default(false)
+ blocked: boolean("blocked").notNull().default(false),
+ approvalState: varchar("approvalState").$type<
+ "pending" | "approved" | "denied"
+ >()
});
export const clientSitesAssociationsCache = pgTable(
@@ -714,6 +719,49 @@ export const clientSiteResourcesAssociationsCache = pgTable(
}
);
+export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
+ snapshotId: serial("snapshotId").primaryKey(),
+
+ clientId: integer("clientId").references(() => clients.clientId, {
+ onDelete: "cascade"
+ }),
+
+ // Platform-agnostic checks
+
+ biometricsEnabled: boolean("biometricsEnabled").notNull().default(false),
+ diskEncrypted: boolean("diskEncrypted").notNull().default(false),
+ firewallEnabled: boolean("firewallEnabled").notNull().default(false),
+ autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false),
+ tpmAvailable: boolean("tpmAvailable").notNull().default(false),
+
+ // Windows-specific posture check information
+
+ windowsDefenderEnabled: boolean("windowsDefenderEnabled")
+ .notNull()
+ .default(false),
+
+ // macOS-specific posture check information
+
+ macosSipEnabled: boolean("macosSipEnabled").notNull().default(false),
+ macosGatekeeperEnabled: boolean("macosGatekeeperEnabled")
+ .notNull()
+ .default(false),
+ macosFirewallStealthMode: boolean("macosFirewallStealthMode")
+ .notNull()
+ .default(false),
+
+ // Linux-specific posture check information
+
+ linuxAppArmorEnabled: boolean("linuxAppArmorEnabled")
+ .notNull()
+ .default(false),
+ linuxSELinuxEnabled: boolean("linuxSELinuxEnabled")
+ .notNull()
+ .default(false),
+
+ collectedAt: integer("collectedAt").notNull()
+});
+
export const olms = pgTable("olms", {
olmId: varchar("id").primaryKey(),
secretHash: varchar("secretHash").notNull(),
@@ -732,6 +780,27 @@ export const olms = pgTable("olms", {
archived: boolean("archived").notNull().default(false)
});
+export const fingerprints = pgTable("fingerprints", {
+ fingerprintId: serial("id").primaryKey(),
+
+ olmId: text("olmId")
+ .references(() => olms.olmId, { onDelete: "cascade" })
+ .notNull(),
+
+ firstSeen: integer("firstSeen").notNull(),
+ lastSeen: integer("lastSeen").notNull(),
+
+ username: text("username"),
+ hostname: text("hostname"),
+ platform: text("platform"), // macos | windows | linux | ios | android | unknown
+ osVersion: text("osVersion"),
+ kernelVersion: text("kernelVersion"),
+ arch: text("arch"),
+ deviceModel: text("deviceModel"),
+ serialNumber: text("serialNumber"),
+ platformFingerprint: varchar("platformFingerprint")
+});
+
export const olmSessions = pgTable("clientSession", {
sessionId: varchar("id").primaryKey(),
olmId: varchar("olmId")
diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts
index 3c6c5420..280c8a11 100644
--- a/server/db/queries/verifySessionQueries.ts
+++ b/server/db/queries/verifySessionQueries.ts
@@ -1,4 +1,4 @@
-import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db";
+import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db";
import {
Resource,
ResourcePassword,
@@ -108,9 +108,17 @@ export async function getUserSessionWithUser(
*/
export async function getUserOrgRole(userId: string, orgId: string) {
const userOrgRole = await db
- .select()
+ .select({
+ userId: userOrgs.userId,
+ orgId: userOrgs.orgId,
+ roleId: userOrgs.roleId,
+ isOwner: userOrgs.isOwner,
+ autoProvisioned: userOrgs.autoProvisioned,
+ roleName: roles.name
+ })
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
+ .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(1);
return userOrgRole.length > 0 ? userOrgRole[0] : null;
diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts
index af7d021d..32aa543e 100644
--- a/server/db/sqlite/schema/privateSchema.ts
+++ b/server/db/sqlite/schema/privateSchema.ts
@@ -6,7 +6,7 @@ import {
sqliteTable,
text
} from "drizzle-orm/sqlite-core";
-import { domains, exitNodes, orgs, sessions, users } from "./schema";
+import { clients, domains, exitNodes, orgs, sessions, users } from "./schema";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -289,6 +289,31 @@ export const accessAuditLog = sqliteTable(
]
);
+export const approvals = sqliteTable("approvals", {
+ approvalId: integer("approvalId").primaryKey({ autoIncrement: true }),
+ timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
+ orgId: text("orgId")
+ .references(() => orgs.orgId, {
+ onDelete: "cascade"
+ })
+ .notNull(),
+ clientId: integer("clientId").references(() => clients.clientId, {
+ onDelete: "cascade"
+ }), // olms reference user devices clients
+ userId: text("userId").references(() => users.userId, {
+ // optionally tied to a user and in this case delete when the user deletes
+ onDelete: "cascade"
+ }),
+ decision: text("decision")
+ .$type<"approved" | "denied" | "pending">()
+ .default("pending")
+ .notNull(),
+ type: text("type")
+ .$type<"user_device" /*| 'proxy' // for later */>()
+ .notNull()
+});
+
+export type Approval = InferSelectModel;
export type Limit = InferSelectModel;
export type Account = InferSelectModel;
export type Certificate = InferSelectModel;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 84211a1e..8fe0152a 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -255,7 +255,9 @@ export const siteResources = sqliteTable("siteResources", {
aliasAddress: text("aliasAddress"),
tcpPortRangeString: text("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: text("udpPortRangeString").notNull().default("*"),
- disableIcmp: integer("disableIcmp", { mode: "boolean" }).notNull().default(false)
+ disableIcmp: integer("disableIcmp", { mode: "boolean" })
+ .notNull()
+ .default(false)
});
export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -385,7 +387,10 @@ export const clients = sqliteTable("clients", {
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
- blocked: integer("blocked", { mode: "boolean" }).notNull().default(false)
+ blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
+ approvalState: text("approvalState").$type<
+ "pending" | "approved" | "denied"
+ >()
});
export const clientSitesAssociationsCache = sqliteTable(
@@ -411,6 +416,69 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
}
);
+export const clientPostureSnapshots = sqliteTable("clientPostureSnapshots", {
+ snapshotId: integer("snapshotId").primaryKey({ autoIncrement: true }),
+
+ clientId: integer("clientId").references(() => clients.clientId, {
+ onDelete: "cascade"
+ }),
+
+ // Platform-agnostic checks
+
+ biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ diskEncrypted: integer("diskEncrypted", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ firewallEnabled: integer("firewallEnabled", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ tpmAvailable: integer("tpmAvailable", { mode: "boolean" })
+ .notNull()
+ .default(false),
+
+ // Windows-specific posture check information
+
+ windowsDefenderEnabled: integer("windowsDefenderEnabled", {
+ mode: "boolean"
+ })
+ .notNull()
+ .default(false),
+
+ // macOS-specific posture check information
+
+ macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ macosGatekeeperEnabled: integer("macosGatekeeperEnabled", {
+ mode: "boolean"
+ })
+ .notNull()
+ .default(false),
+ macosFirewallStealthMode: integer("macosFirewallStealthMode", {
+ mode: "boolean"
+ })
+ .notNull()
+ .default(false),
+
+ // Linux-specific posture check information
+
+ linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ linuxSELinuxEnabled: integer("linuxSELinuxEnabled", {
+ mode: "boolean"
+ })
+ .notNull()
+ .default(false),
+
+ collectedAt: integer("collectedAt").notNull()
+});
+
export const olms = sqliteTable("olms", {
olmId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(),
@@ -429,6 +497,27 @@ export const olms = sqliteTable("olms", {
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
});
+export const fingerprints = sqliteTable("fingerprints", {
+ fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
+
+ olmId: text("olmId")
+ .references(() => olms.olmId, { onDelete: "cascade" })
+ .notNull(),
+
+ firstSeen: integer("firstSeen").notNull(),
+ lastSeen: integer("lastSeen").notNull(),
+
+ username: text("username"),
+ hostname: text("hostname"),
+ platform: text("platform"), // macos | windows | linux | ios | android | unknown
+ osVersion: text("osVersion"),
+ kernelVersion: text("kernelVersion"),
+ arch: text("arch"),
+ deviceModel: text("deviceModel"),
+ serialNumber: text("serialNumber"),
+ platformFingerprint: text("platformFingerprint")
+});
+
export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
codeId: integer("id").primaryKey({ autoIncrement: true }),
userId: text("userId")
@@ -518,7 +607,10 @@ export const roles = sqliteTable("roles", {
.notNull(),
isAdmin: integer("isAdmin", { mode: "boolean" }),
name: text("name").notNull(),
- description: text("description")
+ description: text("description"),
+ requireDeviceApproval: integer("requireDeviceApproval", {
+ mode: "boolean"
+ }).default(false)
});
export const roleActions = sqliteTable("roleActions", {
@@ -777,7 +869,8 @@ export const idp = sqliteTable("idp", {
mode: "boolean"
})
.notNull()
- .default(false)
+ .default(false),
+ tags: text("tags")
});
// Identity Provider OAuth Configuration
diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts
index ac3d719f..0b4a131a 100644
--- a/server/lib/calculateUserClientsForOrgs.ts
+++ b/server/lib/calculateUserClientsForOrgs.ts
@@ -1,21 +1,24 @@
+import { listExitNodes } from "#dynamic/lib/exitNodes";
+import { build } from "@server/build";
import {
+ approvals,
clients,
db,
olms,
orgs,
roleClients,
roles,
+ Transaction,
userClients,
- userOrgs,
- Transaction
+ userOrgs
} from "@server/db";
-import { eq, and, notInArray } from "drizzle-orm";
-import { listExitNodes } from "#dynamic/lib/exitNodes";
-import { getNextAvailableClientSubnet } from "@server/lib/ip";
-import logger from "@server/logger";
-import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
-import { sendTerminateClient } from "@server/routers/client/terminate";
import { getUniqueClientName } from "@server/db/names";
+import { getNextAvailableClientSubnet } from "@server/lib/ip";
+import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
+import logger from "@server/logger";
+import { sendTerminateClient } from "@server/routers/client/terminate";
+import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
+import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
export async function calculateUserClientsForOrgs(
userId: string,
@@ -38,13 +41,15 @@ export async function calculateUserClientsForOrgs(
const allUserOrgs = await transaction
.select()
.from(userOrgs)
+ .innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
.where(eq(userOrgs.userId, userId));
- const userOrgIds = allUserOrgs.map((uo) => uo.orgId);
+ const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId);
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
- for (const userOrg of allUserOrgs) {
+ for (const userRoleOrg of allUserOrgs) {
+ const { userOrgs: userOrg, roles: role } = userRoleOrg;
const orgId = userOrg.orgId;
const [org] = await transaction
@@ -182,21 +187,46 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId);
+ const isOrgLicensed = await isLicensedOrSubscribed(
+ userOrg.orgId
+ );
+ const requireApproval =
+ build !== "oss" &&
+ isOrgLicensed &&
+ role.requireDeviceApproval;
+
+ const newClientData: InferInsertModel = {
+ userId,
+ orgId: userOrg.orgId,
+ exitNodeId: randomExitNode.exitNodeId,
+ name: olm.name || "User Client",
+ subnet: updatedSubnet,
+ olmId: olm.olmId,
+ type: "olm",
+ niceId,
+ approvalState: requireApproval ? "pending" : null
+ };
+
// Create the client
const [newClient] = await transaction
.insert(clients)
- .values({
- userId,
- orgId: userOrg.orgId,
- exitNodeId: randomExitNode.exitNodeId,
- name: olm.name || "User Client",
- subnet: updatedSubnet,
- olmId: olm.olmId,
- type: "olm",
- niceId
- })
+ .values(newClientData)
.returning();
+ // create approval request
+ if (requireApproval) {
+ await transaction
+ .insert(approvals)
+ .values({
+ timestamp: Math.floor(new Date().getTime() / 1000),
+ orgId: userOrg.orgId,
+ clientId: newClient.clientId,
+ userId,
+ type: "user_device"
+ })
+ .returning();
+ }
+
await rebuildClientAssociationsFromClient(
newClient,
transaction
diff --git a/server/private/routers/approvals/index.ts b/server/private/routers/approvals/index.ts
new file mode 100644
index 00000000..40e59cc9
--- /dev/null
+++ b/server/private/routers/approvals/index.ts
@@ -0,0 +1,15 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
+export * from "./listApprovals";
+export * from "./processPendingApproval";
diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts
new file mode 100644
index 00000000..6006e48b
--- /dev/null
+++ b/server/private/routers/approvals/listApprovals.ts
@@ -0,0 +1,188 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
+import logger from "@server/logger";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+
+import type { Request, Response, NextFunction } from "express";
+import { build } from "@server/build";
+import { getOrgTierData } from "@server/lib/billing";
+import { TierId } from "@server/lib/billing/tiers";
+import { approvals, clients, db, users, type Approval } from "@server/db";
+import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
+import response from "@server/lib/response";
+
+const paramsSchema = z.strictObject({
+ orgId: z.string()
+});
+
+const querySchema = z.strictObject({
+ limit: z
+ .string()
+ .optional()
+ .default("1000")
+ .transform(Number)
+ .pipe(z.int().nonnegative()),
+ offset: z
+ .string()
+ .optional()
+ .default("0")
+ .transform(Number)
+ .pipe(z.int().nonnegative()),
+ approvalState: z
+ .enum(["pending", "approved", "denied", "all"])
+ .optional()
+ .default("all")
+ .catch("all")
+});
+
+async function queryApprovals(
+ orgId: string,
+ limit: number,
+ offset: number,
+ approvalState: z.infer["approvalState"]
+) {
+ let state: Array = [];
+ switch (approvalState) {
+ case "pending":
+ state = ["pending"];
+ break;
+ case "approved":
+ state = ["approved"];
+ break;
+ case "denied":
+ state = ["denied"];
+ break;
+ default:
+ state = ["approved", "denied", "pending"];
+ }
+
+ const res = await db
+ .select({
+ approvalId: approvals.approvalId,
+ orgId: approvals.orgId,
+ clientId: approvals.clientId,
+ decision: approvals.decision,
+ type: approvals.type,
+ user: {
+ name: users.name,
+ userId: users.userId,
+ username: users.username
+ }
+ })
+ .from(approvals)
+ .innerJoin(users, and(eq(approvals.userId, users.userId)))
+ .leftJoin(
+ clients,
+ and(
+ eq(approvals.clientId, clients.clientId),
+ not(isNull(clients.userId)) // only user devices
+ )
+ )
+ .where(
+ and(
+ eq(approvals.orgId, orgId),
+ sql`${approvals.decision} in ${state}`
+ )
+ )
+ .orderBy(
+ sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
+ desc(approvals.timestamp)
+ )
+ .limit(limit)
+ .offset(offset);
+ return res;
+}
+
+export type ListApprovalsResponse = {
+ approvals: NonNullable>>;
+ pagination: { total: number; limit: number; offset: number };
+};
+
+export async function listApprovals(
+ req: Request,
+ res: Response,
+ next: NextFunction
+) {
+ try {
+ const parsedParams = paramsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const parsedQuery = querySchema.safeParse(req.query);
+ if (!parsedQuery.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedQuery.error).toString()
+ )
+ );
+ }
+ const { limit, offset, approvalState } = parsedQuery.data;
+
+ const { orgId } = parsedParams.data;
+
+ if (build === "saas") {
+ const { tier } = await getOrgTierData(orgId);
+ const subscribed = tier === TierId.STANDARD;
+ if (!subscribed) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "This organization's current plan does not support this feature."
+ )
+ );
+ }
+ }
+
+ const approvalsList = await queryApprovals(
+ orgId.toString(),
+ limit,
+ offset,
+ approvalState
+ );
+
+ const [{ count }] = await db
+ .select({ count: sql`count(*)` })
+ .from(approvals);
+
+ return response(res, {
+ data: {
+ approvals: approvalsList,
+ pagination: {
+ total: count,
+ limit,
+ offset
+ }
+ },
+ success: true,
+ error: false,
+ message: "Approvals retrieved successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/private/routers/approvals/processPendingApproval.ts b/server/private/routers/approvals/processPendingApproval.ts
new file mode 100644
index 00000000..43639dd7
--- /dev/null
+++ b/server/private/routers/approvals/processPendingApproval.ts
@@ -0,0 +1,142 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
+import logger from "@server/logger";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+
+import { build } from "@server/build";
+import { approvals, clients, db, orgs, type Approval } from "@server/db";
+import { getOrgTierData } from "@server/lib/billing";
+import { TierId } from "@server/lib/billing/tiers";
+import response from "@server/lib/response";
+import { and, eq, type InferInsertModel } from "drizzle-orm";
+import type { NextFunction, Request, Response } from "express";
+
+const paramsSchema = z.strictObject({
+ orgId: z.string(),
+ approvalId: z.string().transform(Number).pipe(z.int().positive())
+});
+
+const bodySchema = z.strictObject({
+ decision: z.enum(["approved", "denied"])
+});
+
+export type ProcessApprovalResponse = Approval;
+
+export async function processPendingApproval(
+ req: Request,
+ res: Response,
+ next: NextFunction
+) {
+ try {
+ const parsedParams = paramsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const parsedBody = bodySchema.safeParse(req.body);
+
+ if (!parsedBody.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedBody.error).toString()
+ )
+ );
+ }
+
+ const { orgId, approvalId } = parsedParams.data;
+
+ if (build === "saas") {
+ const { tier } = await getOrgTierData(orgId);
+ const subscribed = tier === TierId.STANDARD;
+ if (!subscribed) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "This organization's current plan does not support this feature."
+ )
+ );
+ }
+ }
+
+ const updateData = parsedBody.data;
+
+ const approval = await db
+ .select()
+ .from(approvals)
+ .where(
+ and(
+ eq(approvals.approvalId, approvalId),
+ eq(approvals.decision, "pending")
+ )
+ )
+ .innerJoin(orgs, eq(approvals.orgId, approvals.orgId))
+ .limit(1);
+
+ if (approval.length === 0) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ `Pending Approval with ID ${approvalId} not found`
+ )
+ );
+ }
+
+ const [updatedApproval] = await db
+ .update(approvals)
+ .set(updateData)
+ .where(eq(approvals.approvalId, approvalId))
+ .returning();
+
+ // Update user device approval state too
+ if (
+ updatedApproval.type === "user_device" &&
+ updatedApproval.clientId
+ ) {
+ const updateDataBody: Partial> = {
+ approvalState: updateData.decision
+ };
+
+ if (updateData.decision === "denied") {
+ updateDataBody.blocked = true;
+ }
+
+ await db
+ .update(clients)
+ .set(updateDataBody)
+ .where(eq(clients.clientId, updatedApproval.clientId));
+ }
+
+ return response(res, {
+ data: updatedApproval,
+ success: true,
+ error: false,
+ message: "Approval updated successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts
index 97c6db9f..44af3fe9 100644
--- a/server/private/routers/external.ts
+++ b/server/private/routers/external.ts
@@ -24,6 +24,7 @@ import * as generateLicense from "./generatedLicense";
import * as logs from "#private/routers/auditLogs";
import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key";
+import * as approval from "#private/routers/approvals";
import {
verifyOrgAccess,
@@ -311,6 +312,24 @@ authenticated.get(
loginPage.getLoginPage
);
+authenticated.get(
+ "/org/:orgId/approvals",
+ verifyValidLicense,
+ verifyOrgAccess,
+ verifyUserHasAction(ActionsEnum.listApprovals),
+ logActionAudit(ActionsEnum.listApprovals),
+ approval.listApprovals
+);
+
+authenticated.put(
+ "/org/:orgId/approvals/:approvalId",
+ verifyValidLicense,
+ verifyOrgAccess,
+ verifyUserHasAction(ActionsEnum.updateApprovals),
+ logActionAudit(ActionsEnum.updateApprovals),
+ approval.processPendingApproval
+);
+
authenticated.get(
"/org/:orgId/login-page-branding",
verifyValidLicense,
diff --git a/server/private/routers/loginPage/getLoginPageBranding.ts b/server/private/routers/loginPage/getLoginPageBranding.ts
index 262e9ce8..8fd0772d 100644
--- a/server/private/routers/loginPage/getLoginPageBranding.ts
+++ b/server/private/routers/loginPage/getLoginPageBranding.ts
@@ -29,11 +29,9 @@ import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
-const paramsSchema = z
- .object({
- orgId: z.string()
- })
- .strict();
+const paramsSchema = z.strictObject({
+ orgId: z.string()
+});
export async function getLoginPageBranding(
req: Request,
diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts
index 36a5487e..998a159f 100644
--- a/server/private/routers/orgIdp/createOrgOidcIdp.ts
+++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts
@@ -43,7 +43,8 @@ const bodySchema = z.strictObject({
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
- roleMapping: z.string().optional()
+ roleMapping: z.string().optional(),
+ tags: z.string().optional()
});
registry.registerPath({
@@ -104,7 +105,8 @@ export async function createOrgOidcIdp(
name,
autoProvision,
variant,
- roleMapping
+ roleMapping,
+ tags
} = parsedBody.data;
if (build === "saas") {
@@ -132,7 +134,8 @@ export async function createOrgOidcIdp(
.values({
name,
autoProvision,
- type: "oidc"
+ type: "oidc",
+ tags
})
.returning();
diff --git a/server/private/routers/orgIdp/listOrgIdps.ts b/server/private/routers/orgIdp/listOrgIdps.ts
index 61049c49..b6cf48ac 100644
--- a/server/private/routers/orgIdp/listOrgIdps.ts
+++ b/server/private/routers/orgIdp/listOrgIdps.ts
@@ -50,7 +50,8 @@ async function query(orgId: string, limit: number, offset: number) {
orgId: idpOrg.orgId,
name: idp.name,
type: idp.type,
- variant: idpOidcConfig.variant
+ variant: idpOidcConfig.variant,
+ tags: idp.tags
})
.from(idpOrg)
.where(eq(idpOrg.orgId, orgId))
diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts
index 6474abda..d8ef415c 100644
--- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts
+++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts
@@ -46,7 +46,8 @@ const bodySchema = z.strictObject({
namePath: z.string().optional(),
scopes: z.string().optional(),
autoProvision: z.boolean().optional(),
- roleMapping: z.string().optional()
+ roleMapping: z.string().optional(),
+ tags: z.string().optional()
});
export type UpdateOrgIdpResponse = {
@@ -109,7 +110,8 @@ export async function updateOrgOidcIdp(
namePath,
name,
autoProvision,
- roleMapping
+ roleMapping,
+ tags
} = parsedBody.data;
if (build === "saas") {
@@ -167,7 +169,8 @@ export async function updateOrgOidcIdp(
await db.transaction(async (trx) => {
const idpData = {
name,
- autoProvision
+ autoProvision,
+ tags
};
// only update if at least one key is not undefined
diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts
index 4600a4cc..ee08d155 100644
--- a/server/routers/auth/index.ts
+++ b/server/routers/auth/index.ts
@@ -16,4 +16,5 @@ export * from "./checkResourceSession";
export * from "./securityKey";
export * from "./startDeviceWebAuth";
export * from "./verifyDeviceWebAuth";
-export * from "./pollDeviceWebAuth";
\ No newline at end of file
+export * from "./pollDeviceWebAuth";
+export * from "./lookupUser";
\ No newline at end of file
diff --git a/server/routers/auth/lookupUser.ts b/server/routers/auth/lookupUser.ts
new file mode 100644
index 00000000..83894927
--- /dev/null
+++ b/server/routers/auth/lookupUser.ts
@@ -0,0 +1,224 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import {
+ users,
+ userOrgs,
+ orgs,
+ idpOrg,
+ idp,
+ idpOidcConfig
+} from "@server/db";
+import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm";
+import response from "@server/lib/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import logger from "@server/logger";
+import { fromError } from "zod-validation-error";
+import { OpenAPITags, registry } from "@server/openApi";
+import { UserType } from "@server/types/UserTypes";
+
+const lookupBodySchema = z.strictObject({
+ identifier: z.string().min(1).toLowerCase()
+});
+
+export type LookupUserResponse = {
+ found: boolean;
+ identifier: string;
+ accounts: Array<{
+ userId: string;
+ email: string | null;
+ username: string;
+ hasInternalAuth: boolean;
+ orgs: Array<{
+ orgId: string;
+ orgName: string;
+ idps: Array<{
+ idpId: number;
+ name: string;
+ variant: string | null;
+ }>;
+ hasInternalAuth: boolean;
+ }>;
+ }>;
+};
+
+// registry.registerPath({
+// method: "post",
+// path: "/auth/lookup-user",
+// description: "Lookup user accounts by username or email and return available authentication methods.",
+// tags: [OpenAPITags.Auth],
+// request: {
+// body: lookupBodySchema
+// },
+// responses: {}
+// });
+
+export async function lookupUser(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedBody = lookupBodySchema.safeParse(req.body);
+ if (!parsedBody.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedBody.error).toString()
+ )
+ );
+ }
+
+ const { identifier } = parsedBody.data;
+
+ // Query users matching identifier (case-insensitive)
+ // Match by username OR email
+ const matchingUsers = await db
+ .select({
+ userId: users.userId,
+ email: users.email,
+ username: users.username,
+ type: users.type,
+ passwordHash: users.passwordHash,
+ idpId: users.idpId
+ })
+ .from(users)
+ .where(
+ or(
+ sql`LOWER(${users.username}) = ${identifier}`,
+ sql`LOWER(${users.email}) = ${identifier}`
+ )
+ );
+
+ if (!matchingUsers || matchingUsers.length === 0) {
+ return response(res, {
+ data: {
+ found: false,
+ identifier,
+ accounts: []
+ },
+ success: true,
+ error: false,
+ message: "No accounts found",
+ status: HttpCode.OK
+ });
+ }
+
+ // Get unique user IDs
+ const userIds = [...new Set(matchingUsers.map((u) => u.userId))];
+
+ // Get all org memberships for these users
+ const orgMemberships = await db
+ .select({
+ userId: userOrgs.userId,
+ orgId: userOrgs.orgId,
+ orgName: orgs.name
+ })
+ .from(userOrgs)
+ .innerJoin(orgs, eq(orgs.orgId, userOrgs.orgId))
+ .where(inArray(userOrgs.userId, userIds));
+
+ // Get unique org IDs
+ const orgIds = [...new Set(orgMemberships.map((m) => m.orgId))];
+
+ // Get all IdPs for these orgs
+ const orgIdps =
+ orgIds.length > 0
+ ? await db
+ .select({
+ orgId: idpOrg.orgId,
+ idpId: idp.idpId,
+ idpName: idp.name,
+ variant: idpOidcConfig.variant
+ })
+ .from(idpOrg)
+ .innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
+ .innerJoin(
+ idpOidcConfig,
+ eq(idpOidcConfig.idpId, idp.idpId)
+ )
+ .where(inArray(idpOrg.orgId, orgIds))
+ : [];
+
+ // Build response structure
+ const accounts: LookupUserResponse["accounts"] = [];
+
+ for (const user of matchingUsers) {
+ const hasInternalAuth =
+ user.type === UserType.Internal && user.passwordHash !== null;
+
+ // Get orgs for this user
+ const userOrgMemberships = orgMemberships.filter(
+ (m) => m.userId === user.userId
+ );
+
+ // Deduplicate orgs (user might have multiple memberships in same org)
+ const uniqueOrgs = new Map();
+ for (const membership of userOrgMemberships) {
+ if (!uniqueOrgs.has(membership.orgId)) {
+ uniqueOrgs.set(membership.orgId, membership);
+ }
+ }
+
+ const orgsData = Array.from(uniqueOrgs.values()).map((membership) => {
+ // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
+ // Only show IdPs where the user's idpId matches
+ // Internal users don't have an idpId, so they won't see any IdPs
+ const orgIdpsList = orgIdps
+ .filter((idp) => {
+ if (idp.orgId !== membership.orgId) {
+ return false;
+ }
+ // Only show IdPs where the user (with exact identifier) is authenticated via that IdP
+ // This means user.idpId must match idp.idpId
+ if (user.idpId !== null && user.idpId === idp.idpId) {
+ return true;
+ }
+ return false;
+ })
+ .map((idp) => ({
+ idpId: idp.idpId,
+ name: idp.idpName,
+ variant: idp.variant
+ }));
+
+ // Check if user has internal auth for this org
+ // User has internal auth if they have an internal account type
+ const orgHasInternalAuth = hasInternalAuth;
+
+ return {
+ orgId: membership.orgId,
+ orgName: membership.orgName,
+ idps: orgIdpsList,
+ hasInternalAuth: orgHasInternalAuth
+ };
+ });
+
+ accounts.push({
+ userId: user.userId,
+ email: user.email,
+ username: user.username,
+ hasInternalAuth,
+ orgs: orgsData
+ });
+ }
+
+ return response(res, {
+ data: {
+ found: true,
+ identifier,
+ accounts
+ },
+ success: true,
+ error: false,
+ message: "User lookup completed",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/routers/auth/verifyDeviceWebAuth.ts b/server/routers/auth/verifyDeviceWebAuth.ts
index be0e0ff2..756749f5 100644
--- a/server/routers/auth/verifyDeviceWebAuth.ts
+++ b/server/routers/auth/verifyDeviceWebAuth.ts
@@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { unauthorized } from "@server/auth/unauthorizedResponse";
+import { getIosDeviceName, getMacDeviceName } from "@server/db/names";
const bodySchema = z
.object({
@@ -120,6 +121,11 @@ export async function verifyDeviceWebAuth(
);
}
+ const deviceName =
+ getMacDeviceName(deviceCode.deviceName) ||
+ getIosDeviceName(deviceCode.deviceName) ||
+ deviceCode.deviceName;
+
// If verify is false, just return metadata without verifying
if (!verify) {
return response(res, {
@@ -129,7 +135,7 @@ export async function verifyDeviceWebAuth(
metadata: {
ip: deviceCode.ip,
city: deviceCode.city,
- deviceName: deviceCode.deviceName,
+ deviceName: deviceName,
applicationName: deviceCode.applicationName,
createdAt: deviceCode.createdAt
}
diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts
index 8dee788a..3226755d 100644
--- a/server/routers/badger/verifySession.ts
+++ b/server/routers/badger/verifySession.ts
@@ -942,7 +942,7 @@ async function isUserAllowedToAccessResource(
username: user.username,
email: user.email,
name: user.name,
- role: user.role
+ role: userOrgRole.roleName
};
}
@@ -956,7 +956,7 @@ async function isUserAllowedToAccessResource(
username: user.username,
email: user.email,
name: user.name,
- role: user.role
+ role: userOrgRole.roleName
};
}
diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts
index e1a00ff6..68ae64f8 100644
--- a/server/routers/client/blockClient.ts
+++ b/server/routers/client/blockClient.ts
@@ -73,7 +73,7 @@ export async function blockClient(
// Block the client
await trx
.update(clients)
- .set({ blocked: true })
+ .set({ blocked: true, approvalState: "denied" })
.where(eq(clients.clientId, clientId));
// Send terminate signal if there's an associated OLM and it's connected
diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts
index f054ce80..709eb1a5 100644
--- a/server/routers/client/getClient.ts
+++ b/server/routers/client/getClient.ts
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, olms } from "@server/db";
-import { clients } from "@server/db";
+import { clients, fingerprints } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -10,6 +10,7 @@ import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
+import { getUserDeviceName } from "@server/db/names";
const getClientSchema = z.strictObject({
clientId: z
@@ -29,6 +30,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
.from(clients)
.where(eq(clients.clientId, clientId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
+ .leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.limit(1);
return res;
} else if (niceId && orgId) {
@@ -37,6 +39,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
.from(clients)
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
+ .leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.limit(1);
return res;
}
@@ -105,8 +108,16 @@ export async function getClient(
);
}
+ // Replace name with device name if OLM exists
+ let clientName = client.clients.name;
+ if (client.olms) {
+ const model = client.fingerprints?.deviceModel || null;
+ clientName = getUserDeviceName(model, client.clients.name);
+ }
+
const data: GetClientResponse = {
...client.clients,
+ name: clientName,
olmId: client.olms ? client.olms.olmId : null
};
diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts
index 18bc3e38..99857261 100644
--- a/server/routers/client/listClients.ts
+++ b/server/routers/client/listClients.ts
@@ -5,7 +5,8 @@ import {
roleClients,
sites,
userClients,
- clientSitesAssociationsCache
+ clientSitesAssociationsCache,
+ fingerprints
} from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -27,6 +28,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import NodeCache from "node-cache";
import semver from "semver";
+import { getUserDeviceName } from "@server/db/names";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
@@ -137,14 +139,17 @@ function queryClients(
userEmail: users.email,
niceId: clients.niceId,
agent: olms.agent,
+ approvalState: clients.approvalState,
olmArchived: olms.archived,
archived: clients.archived,
- blocked: clients.blocked
+ blocked: clients.blocked,
+ deviceModel: fingerprints.deviceModel
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
+ .leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.where(and(...conditions));
}
@@ -163,21 +168,22 @@ async function getSiteAssociations(clientIds: number[]) {
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
}
-type OlmWithUpdateAvailable = Awaited>[0] & {
+type ClientWithSites = Omit<
+ Awaited>[0],
+ "deviceModel"
+> & {
+ sites: Array<{
+ siteId: number;
+ siteName: string | null;
+ siteNiceId: string | null;
+ }>;
olmUpdateAvailable?: boolean;
};
+type OlmWithUpdateAvailable = ClientWithSites;
+
export type ListClientsResponse = {
- clients: Array<
- Awaited>[0] & {
- sites: Array<{
- siteId: number;
- siteName: string | null;
- siteNiceId: string | null;
- }>;
- olmUpdateAvailable?: boolean;
- }
- >;
+ clients: Array;
pagination: { total: number; limit: number; offset: number };
};
@@ -307,11 +313,17 @@ export async function listClients(
>
);
- // Merge clients with their site associations
- const clientsWithSites = clientsList.map((client) => ({
- ...client,
- sites: sitesByClient[client.clientId] || []
- }));
+ // Merge clients with their site associations and replace name with device name
+ const clientsWithSites = clientsList.map((client) => {
+ const model = client.deviceModel || null;
+ const newName = getUserDeviceName(model, client.name);
+ const { deviceModel, ...clientWithoutDeviceModel } = client;
+ return {
+ ...clientWithoutDeviceModel,
+ name: newName,
+ sites: sitesByClient[client.clientId] || []
+ };
+ });
const latestOlVersionPromise = getLatestOlmVersion();
@@ -350,7 +362,7 @@ export async function listClients(
return response(res, {
data: {
- clients: clientsWithSites,
+ clients: olmsWithUpdates,
pagination: {
total: totalCount,
limit,
diff --git a/server/routers/client/unblockClient.ts b/server/routers/client/unblockClient.ts
index 82b608a2..a16a1030 100644
--- a/server/routers/client/unblockClient.ts
+++ b/server/routers/client/unblockClient.ts
@@ -71,7 +71,7 @@ export async function unblockClient(
// Unblock the client
await db
.update(clients)
- .set({ blocked: false })
+ .set({ blocked: false, approvalState: null })
.where(eq(clients.clientId, clientId));
return response(res, {
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 9b6490a5..3ea60983 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -586,6 +586,14 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.listRoles),
role.listRoles
);
+
+authenticated.post(
+ "/org/:orgId/role/:roleId",
+ verifyOrgAccess,
+ verifyUserHasAction(ActionsEnum.updateRole),
+ logActionAudit(ActionsEnum.updateRole),
+ role.updateRole
+);
// authenticated.get(
// "/role/:roleId",
// verifyRoleAccess,
@@ -861,6 +869,12 @@ authenticated.get(
olm.getUserOlm
);
+authenticated.post(
+ "/user/:userId/olm/recover",
+ verifyIsLoggedInUser,
+ olm.recoverOlmWithFingerprint
+);
+
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
@@ -1107,6 +1121,21 @@ authRouter.post(
auth.login
);
authRouter.post("/logout", auth.logout);
+authRouter.post(
+ "/lookup-user",
+ rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 15,
+ keyGenerator: (req) =>
+ `lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`,
+ handler: (req, res, next) => {
+ const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`;
+ return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
+ },
+ store: createStore()
+ }),
+ auth.lookupUser
+);
authRouter.post(
"/newt/get-token",
rateLimit({
diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts
index c7eeaf30..083bbeb0 100644
--- a/server/routers/idp/createOidcIdp.ts
+++ b/server/routers/idp/createOidcIdp.ts
@@ -24,7 +24,8 @@ const bodySchema = z.strictObject({
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().nonempty(),
- autoProvision: z.boolean().optional()
+ autoProvision: z.boolean().optional(),
+ tags: z.string().optional()
});
export type CreateIdpResponse = {
@@ -75,7 +76,8 @@ export async function createOidcIdp(
emailPath,
namePath,
name,
- autoProvision
+ autoProvision,
+ tags
} = parsedBody.data;
const key = config.getRawConfig().server.secret!;
@@ -90,7 +92,8 @@ export async function createOidcIdp(
.values({
name,
autoProvision,
- type: "oidc"
+ type: "oidc",
+ tags
})
.returning();
diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts
index 20d1899e..9dda11bb 100644
--- a/server/routers/idp/listIdps.ts
+++ b/server/routers/idp/listIdps.ts
@@ -33,7 +33,8 @@ async function query(limit: number, offset: number) {
type: idp.type,
variant: idpOidcConfig.variant,
orgCount: sql`count(${idpOrg.orgId})`,
- autoProvision: idp.autoProvision
+ autoProvision: idp.autoProvision,
+ tags: idp.tags
})
.from(idp)
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts
index a4d55187..622d3d49 100644
--- a/server/routers/idp/updateOidcIdp.ts
+++ b/server/routers/idp/updateOidcIdp.ts
@@ -30,7 +30,8 @@ const bodySchema = z.strictObject({
scopes: z.string().optional(),
autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(),
- defaultOrgMapping: z.string().optional()
+ defaultOrgMapping: z.string().optional(),
+ tags: z.string().optional()
});
export type UpdateIdpResponse = {
@@ -94,7 +95,8 @@ export async function updateOidcIdp(
name,
autoProvision,
defaultRoleMapping,
- defaultOrgMapping
+ defaultOrgMapping,
+ tags
} = parsedBody.data;
// Check if IDP exists and is of type OIDC
@@ -127,7 +129,8 @@ export async function updateOidcIdp(
name,
autoProvision,
defaultRoleMapping,
- defaultOrgMapping
+ defaultOrgMapping,
+ tags
};
// only update if at least one key is not undefined
diff --git a/server/routers/integration.ts b/server/routers/integration.ts
index 3373285b..7a5a3efe 100644
--- a/server/routers/integration.ts
+++ b/server/routers/integration.ts
@@ -467,6 +467,14 @@ authenticated.put(
role.createRole
);
+authenticated.post(
+ "/org/:orgId/role/:roleId",
+ verifyApiKeyOrgAccess,
+ verifyApiKeyHasAction(ActionsEnum.updateRole),
+ logActionAudit(ActionsEnum.updateRole),
+ role.updateRole
+);
+
authenticated.get(
"/org/:orgId/roles",
verifyApiKeyOrgAccess,
diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts
index aa9b89af..dc0bfde3 100644
--- a/server/routers/olm/getUserOlm.ts
+++ b/server/routers/olm/getUserOlm.ts
@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { db } from "@server/db";
-import { olms } from "@server/db";
+import { olms, clients, fingerprints } from "@server/db";
import { eq, and } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
+import { getUserDeviceName } from "@server/db/names";
const paramsSchema = z
.object({
@@ -17,6 +18,10 @@ const paramsSchema = z
})
.strict();
+const querySchema = z.object({
+ orgId: z.string().optional()
+});
+
// registry.registerPath({
// method: "get",
// path: "/user/{userId}/olm/{olmId}",
@@ -44,15 +49,64 @@ export async function getUserOlm(
);
}
- const { olmId, userId } = parsedParams.data;
+ const parsedQuery = querySchema.safeParse(req.query);
+ if (!parsedQuery.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedQuery.error).toString()
+ )
+ );
+ }
- const [olm] = await db
+ const { olmId, userId } = parsedParams.data;
+ const { orgId } = parsedQuery.data;
+
+ const [result] = await db
.select()
.from(olms)
- .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)));
+ .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)))
+ .leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
+ .limit(1);
+
+ if (!result || !result.olms) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "Olm not found"
+ )
+ );
+ }
+
+ const olm = result.olms;
+
+ // If orgId is provided and olm has a clientId, fetch the client to check blocked status
+ let blocked: boolean | undefined;
+ if (orgId && olm.clientId) {
+ const [client] = await db
+ .select({ blocked: clients.blocked })
+ .from(clients)
+ .where(
+ and(
+ eq(clients.clientId, olm.clientId),
+ eq(clients.orgId, orgId)
+ )
+ )
+ .limit(1);
+
+ blocked = client?.blocked ?? false;
+ }
+
+ // Replace name with device name
+ const model = result.fingerprints?.deviceModel || null;
+ const newName = getUserDeviceName(model, olm.name);
+
+ const responseData = blocked !== undefined
+ ? { ...olm, name: newName, blocked }
+ : { ...olm, name: newName };
return response(res, {
- data: olm,
+ data: responseData,
success: true,
error: false,
message: "Successfully retrieved olm",
diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts
index 359da3c9..bfcb7f33 100644
--- a/server/routers/olm/handleOlmPingMessage.ts
+++ b/server/routers/olm/handleOlmPingMessage.ts
@@ -1,5 +1,5 @@
-import { db } from "@server/db";
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
+import { clientPostureSnapshots, db, fingerprints } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, olms, Olm } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
@@ -102,7 +102,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
- const { userToken } = message.data;
+ const { userToken, fingerprint, postures } = message.data;
if (!olm) {
logger.warn("Olm not found");
@@ -206,6 +206,74 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
logger.error("Error handling ping message", { error });
}
+ const now = Math.floor(Date.now() / 1000);
+
+ if (fingerprint && olm.olmId) {
+ const [existingFingerprint] = await db
+ .select()
+ .from(fingerprints)
+ .where(eq(fingerprints.olmId, olm.olmId))
+ .limit(1);
+
+ if (!existingFingerprint) {
+ await db.insert(fingerprints).values({
+ olmId: olm.olmId,
+ firstSeen: now,
+ lastSeen: now,
+
+ username: fingerprint.username,
+ hostname: fingerprint.hostname,
+ platform: fingerprint.platform,
+ osVersion: fingerprint.osVersion,
+ kernelVersion: fingerprint.kernelVersion,
+ arch: fingerprint.arch,
+ deviceModel: fingerprint.deviceModel,
+ serialNumber: fingerprint.serialNumber,
+ platformFingerprint: fingerprint.platformFingerprint
+ });
+ } else {
+ await db
+ .update(fingerprints)
+ .set({
+ lastSeen: now,
+
+ username: fingerprint.username,
+ hostname: fingerprint.hostname,
+ platform: fingerprint.platform,
+ osVersion: fingerprint.osVersion,
+ kernelVersion: fingerprint.kernelVersion,
+ arch: fingerprint.arch,
+ deviceModel: fingerprint.deviceModel,
+ serialNumber: fingerprint.serialNumber,
+ platformFingerprint: fingerprint.platformFingerprint
+ })
+ .where(eq(fingerprints.olmId, olm.olmId));
+ }
+ }
+
+ if (postures && olm.clientId) {
+ await db.insert(clientPostureSnapshots).values({
+ clientId: olm.clientId,
+
+ biometricsEnabled: postures?.biometricsEnabled,
+ diskEncrypted: postures?.diskEncrypted,
+ firewallEnabled: postures?.firewallEnabled,
+ autoUpdatesEnabled: postures?.autoUpdatesEnabled,
+ tpmAvailable: postures?.tpmAvailable,
+
+ windowsDefenderEnabled: postures?.windowsDefenderEnabled,
+
+ macosSipEnabled: postures?.macosSipEnabled,
+ macosGatekeeperEnabled: postures?.macosGatekeeperEnabled,
+ macosFirewallStealthMode: postures?.macosFirewallStealthMode,
+
+ linuxAppArmorEnabled: postures?.linuxAppArmorEnabled,
+ linuxSELinuxEnabled: postures?.linuxSELinuxEnabled,
+
+ collectedAt: now
+ });
+ }
+
return {
message: {
type: "pong",
diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts
index 5dfa9520..46241603 100644
--- a/server/routers/olm/handleOlmRegisterMessage.ts
+++ b/server/routers/olm/handleOlmRegisterMessage.ts
@@ -1,7 +1,9 @@
import {
Client,
+ clientPostureSnapshots,
clientSiteResourcesAssociationsCache,
db,
+ fingerprints,
orgs,
siteResources
} from "@server/db";
@@ -38,8 +40,16 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
- const { publicKey, relay, olmVersion, olmAgent, orgId, userToken } =
- message.data;
+ const {
+ publicKey,
+ relay,
+ olmVersion,
+ olmAgent,
+ orgId,
+ userToken,
+ fingerprint,
+ postures
+ } = message.data;
if (!olm.clientId) {
logger.warn("Olm client ID not found");
@@ -188,6 +198,72 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
relay
);
+ if (fingerprint) {
+ const [existingFingerprint] = await db
+ .select()
+ .from(fingerprints)
+ .where(eq(fingerprints.olmId, olm.olmId))
+ .limit(1);
+
+ if (!existingFingerprint) {
+ await db.insert(fingerprints).values({
+ olmId: olm.olmId,
+ firstSeen: now,
+ lastSeen: now,
+
+ username: fingerprint.username,
+ hostname: fingerprint.hostname,
+ platform: fingerprint.platform,
+ osVersion: fingerprint.osVersion,
+ kernelVersion: fingerprint.kernelVersion,
+ arch: fingerprint.arch,
+ deviceModel: fingerprint.deviceModel,
+ serialNumber: fingerprint.serialNumber,
+ platformFingerprint: fingerprint.platformFingerprint
+ });
+ } else {
+ await db
+ .update(fingerprints)
+ .set({
+ lastSeen: now,
+
+ username: fingerprint.username,
+ hostname: fingerprint.hostname,
+ platform: fingerprint.platform,
+ osVersion: fingerprint.osVersion,
+ kernelVersion: fingerprint.kernelVersion,
+ arch: fingerprint.arch,
+ deviceModel: fingerprint.deviceModel,
+ serialNumber: fingerprint.serialNumber,
+ platformFingerprint: fingerprint.platformFingerprint
+ })
+ .where(eq(fingerprints.olmId, olm.olmId));
+ }
+ }
+
+ if (postures && olm.clientId) {
+ await db.insert(clientPostureSnapshots).values({
+ clientId: olm.clientId,
+
+ biometricsEnabled: postures?.biometricsEnabled,
+ diskEncrypted: postures?.diskEncrypted,
+ firewallEnabled: postures?.firewallEnabled,
+ autoUpdatesEnabled: postures?.autoUpdatesEnabled,
+ tpmAvailable: postures?.tpmAvailable,
+
+ windowsDefenderEnabled: postures?.windowsDefenderEnabled,
+
+ macosSipEnabled: postures?.macosSipEnabled,
+ macosGatekeeperEnabled: postures?.macosGatekeeperEnabled,
+ macosFirewallStealthMode: postures?.macosFirewallStealthMode,
+
+ linuxAppArmorEnabled: postures?.linuxAppArmorEnabled,
+ linuxSELinuxEnabled: postures?.linuxSELinuxEnabled,
+
+ collectedAt: now
+ });
+ }
+
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
// if (siteConfigurations.length === 0) {
// logger.warn("No valid site configurations found");
diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts
index 6957c18b..c9017911 100644
--- a/server/routers/olm/index.ts
+++ b/server/routers/olm/index.ts
@@ -9,3 +9,4 @@ export * from "./listUserOlms";
export * from "./getUserOlm";
export * from "./handleOlmServerPeerAddMessage";
export * from "./handleOlmUnRelayMessage";
+export * from "./recoverOlmWithFingerprint";
diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts
index 16585e9f..e8398798 100644
--- a/server/routers/olm/listUserOlms.ts
+++ b/server/routers/olm/listUserOlms.ts
@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express";
-import { db } from "@server/db";
+import { db, fingerprints } from "@server/db";
import { olms } from "@server/db";
import { eq, count, desc } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
+import { getUserDeviceName } from "@server/db/names";
const querySchema = z.object({
limit: z
@@ -99,22 +100,30 @@ export async function listUserOlms(
const total = totalCountResult?.count || 0;
// Get OLMs for the current user (including archived OLMs)
- const userOlms = await db
- .select({
- olmId: olms.olmId,
- dateCreated: olms.dateCreated,
- version: olms.version,
- name: olms.name,
- clientId: olms.clientId,
- userId: olms.userId,
- archived: olms.archived
- })
+ const list = await db
+ .select()
.from(olms)
.where(eq(olms.userId, userId))
+ .leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.orderBy(desc(olms.dateCreated))
.limit(limit)
.offset(offset);
+ const userOlms = list.map((item) => {
+ const model = item.fingerprints?.deviceModel || null;
+ const newName = getUserDeviceName(model, item.olms.name);
+
+ return {
+ olmId: item.olms.olmId,
+ dateCreated: item.olms.dateCreated,
+ version: item.olms.version,
+ name: newName,
+ clientId: item.olms.clientId,
+ userId: item.olms.userId,
+ archived: item.olms.archived
+ };
+ });
+
return response(res, {
data: {
olms: userOlms,
diff --git a/server/routers/olm/recoverOlmWithFingerprint.ts b/server/routers/olm/recoverOlmWithFingerprint.ts
new file mode 100644
index 00000000..49f0542f
--- /dev/null
+++ b/server/routers/olm/recoverOlmWithFingerprint.ts
@@ -0,0 +1,120 @@
+import { db, fingerprints, olms } from "@server/db";
+import logger from "@server/logger";
+import HttpCode from "@server/types/HttpCode";
+import { and, eq } from "drizzle-orm";
+import { NextFunction, Request, Response } from "express";
+import response from "@server/lib/response";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+import { generateId } from "@server/auth/sessions/app";
+import { hashPassword } from "@server/auth/password";
+
+const paramsSchema = z
+ .object({
+ userId: z.string()
+ })
+ .strict();
+
+const bodySchema = z
+ .object({
+ platformFingerprint: z.string()
+ })
+ .strict();
+
+export async function recoverOlmWithFingerprint(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedParams = paramsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const { userId } = parsedParams.data;
+
+ const parsedBody = bodySchema.safeParse(req.body);
+ if (!parsedBody.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedBody.error).toString()
+ )
+ );
+ }
+
+ const { platformFingerprint } = parsedBody.data;
+
+ const result = await db
+ .select({
+ olm: olms,
+ fingerprint: fingerprints
+ })
+ .from(olms)
+ .innerJoin(fingerprints, eq(fingerprints.olmId, olms.olmId))
+ .where(
+ and(
+ eq(olms.userId, userId),
+ eq(olms.archived, false),
+ eq(fingerprints.platformFingerprint, platformFingerprint)
+ )
+ )
+ .orderBy(fingerprints.lastSeen);
+
+ if (!result || result.length == 0) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "corresponding olm with this fingerprint not found"
+ )
+ );
+ }
+
+ if (result.length > 1) {
+ return next(
+ createHttpError(
+ HttpCode.CONFLICT,
+ "multiple matching fingerprints found, not resetting secrets"
+ )
+ );
+ }
+
+ const [{ olm: foundOlm }] = result;
+
+ const newSecret = generateId(48);
+ const newSecretHash = await hashPassword(newSecret);
+
+ await db
+ .update(olms)
+ .set({
+ secretHash: newSecretHash
+ })
+ .where(eq(olms.olmId, foundOlm.olmId));
+
+ return response(res, {
+ data: {
+ olmId: foundOlm.olmId,
+ secret: newSecret
+ },
+ success: true,
+ error: false,
+ message: "Successfully retrieved olm",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Failed to recover olm using provided fingerprint input"
+ )
+ );
+ }
+}
diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts
index 16696af4..a1e21d7a 100644
--- a/server/routers/role/createRole.ts
+++ b/server/routers/role/createRole.ts
@@ -10,6 +10,8 @@ import { fromError } from "zod-validation-error";
import { ActionsEnum } from "@server/auth/actions";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
+import { build } from "@server/build";
+import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const createRoleParamsSchema = z.strictObject({
orgId: z.string()
@@ -17,7 +19,8 @@ const createRoleParamsSchema = z.strictObject({
const createRoleSchema = z.strictObject({
name: z.string().min(1).max(255),
- description: z.string().optional()
+ description: z.string().optional(),
+ requireDeviceApproval: z.boolean().optional()
});
export const defaultRoleAllowedActions: ActionsEnum[] = [
@@ -97,6 +100,11 @@ export async function createRole(
);
}
+ const isLicensed = await isLicensedOrSubscribed(orgId);
+ if (build === "oss" || !isLicensed) {
+ roleData.requireDeviceApproval = undefined;
+ }
+
await db.transaction(async (trx) => {
const newRole = await trx
.insert(roles)
diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts
index 288a540d..ec7f3b4b 100644
--- a/server/routers/role/listRoles.ts
+++ b/server/routers/role/listRoles.ts
@@ -1,15 +1,13 @@
-import { Request, Response, NextFunction } from "express";
-import { z } from "zod";
-import { db } from "@server/db";
-import { roles, orgs } from "@server/db";
+import { db, orgs, roles } from "@server/db";
import response from "@server/lib/response";
-import HttpCode from "@server/types/HttpCode";
-import createHttpError from "http-errors";
-import { sql, eq } from "drizzle-orm";
import logger from "@server/logger";
-import { fromError } from "zod-validation-error";
-import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi";
+import HttpCode from "@server/types/HttpCode";
+import { eq, sql } from "drizzle-orm";
+import { NextFunction, Request, Response } from "express";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
const listRolesParamsSchema = z.strictObject({
orgId: z.string()
@@ -38,7 +36,8 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
isAdmin: roles.isAdmin,
name: roles.name,
description: roles.description,
- orgName: orgs.name
+ orgName: orgs.name,
+ requireDeviceApproval: roles.requireDeviceApproval
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts
index c9f63a7b..0eeef100 100644
--- a/server/routers/role/updateRole.ts
+++ b/server/routers/role/updateRole.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db } from "@server/db";
+import { db, orgs, type Role } from "@server/db";
import { roles } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -8,20 +8,28 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
+import { build } from "@server/build";
+import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const updateRoleParamsSchema = z.strictObject({
+ orgId: z.string(),
roleId: z.string().transform(Number).pipe(z.int().positive())
});
const updateRoleBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
- description: z.string().optional()
+ description: z.string().optional(),
+ requireDeviceApproval: z.boolean().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
});
+export type UpdateRoleBody = z.infer;
+
+export type UpdateRoleResponse = Role;
+
export async function updateRole(
req: Request,
res: Response,
@@ -48,13 +56,14 @@ export async function updateRole(
);
}
- const { roleId } = parsedParams.data;
+ const { roleId, orgId } = parsedParams.data;
const updateData = parsedBody.data;
const role = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
+ .innerJoin(orgs, eq(roles.orgId, orgs.orgId))
.limit(1);
if (role.length === 0) {
@@ -66,7 +75,7 @@ export async function updateRole(
);
}
- if (role[0].isAdmin) {
+ if (role[0].roles.isAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -75,6 +84,11 @@ export async function updateRole(
);
}
+ const isLicensed = await isLicensedOrSubscribed(orgId);
+ if (build === "oss" || !isLicensed) {
+ updateData.requireDeviceApproval = undefined;
+ }
+
const updatedRole = await db
.update(roles)
.set(updateData)
diff --git a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx
new file mode 100644
index 00000000..0fef0d78
--- /dev/null
+++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx
@@ -0,0 +1,52 @@
+import { ApprovalFeed } from "@app/components/ApprovalFeed";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { internal } from "@app/lib/api";
+import { authCookieHeader } from "@app/lib/api/cookies";
+import { getCachedOrg } from "@app/lib/api/getCachedOrg";
+import type { ApprovalItem } from "@app/lib/queries";
+import OrgProvider from "@app/providers/OrgProvider";
+import type { GetOrgResponse } from "@server/routers/org";
+import type { AxiosResponse } from "axios";
+import { getTranslations } from "next-intl/server";
+
+export interface ApprovalFeedPageProps {
+ params: Promise<{ orgId: string }>;
+}
+
+export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
+ const params = await props.params;
+
+ let approvals: ApprovalItem[] = [];
+ const res = await internal
+ .get<
+ AxiosResponse<{ approvals: ApprovalItem[] }>
+ >(`/org/${params.orgId}/approvals`, await authCookieHeader())
+ .catch((e) => {});
+
+ if (res && res.status === 200) {
+ approvals = res.data.data.approvals;
+ }
+
+ let org: GetOrgResponse | null = null;
+ const orgRes = await getCachedOrg(params.orgId);
+
+ if (orgRes && orgRes.status === 200) {
+ org = orgRes.data.data;
+ }
+
+ const t = await getTranslations();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
index f6260073..5ae4f237 100644
--- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import {
SettingsContainer,
SettingsSection,
@@ -10,6 +11,10 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
+import HeaderTitle from "@app/components/SettingsSectionTitle";
+import { StrategySelect } from "@app/components/StrategySelect";
+import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
+import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
@@ -19,29 +24,21 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
-import HeaderTitle from "@app/components/SettingsSectionTitle";
-import { z } from "zod";
-import { createElement, useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
-import { Button } from "@app/components/ui/button";
-import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
-import { toast } from "@app/hooks/useToast";
-import { useParams, useRouter } from "next/navigation";
-import { Checkbox } from "@app/components/ui/checkbox";
-import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
-import { InfoIcon, ExternalLink } from "lucide-react";
-import { StrategySelect } from "@app/components/StrategySelect";
-import { SwitchInput } from "@app/components/SwitchInput";
-import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ListRolesResponse } from "@server/routers/role";
+import { AxiosResponse } from "axios";
+import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
-import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
-import { AxiosResponse } from "axios";
-import { ListRolesResponse } from "@server/routers/role";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
export default function Page() {
const { env } = useEnvContext();
diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx
index c4818abe..7165d9e6 100644
--- a/src/app/[orgId]/settings/access/roles/page.tsx
+++ b/src/app/[orgId]/settings/access/roles/page.tsx
@@ -2,12 +2,12 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { GetOrgResponse } from "@server/routers/org";
-import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role";
-import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
+import RolesTable, { type RoleRow } from "@app/components/RolesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
+import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type RolesPageProps = {
params: Promise<{ orgId: string }>;
@@ -47,14 +47,7 @@ export default async function RolesPage(props: RolesPageProps) {
}
let org: GetOrgResponse | null = null;
- const getOrg = cache(async () =>
- internal
- .get<
- AxiosResponse
- >(`/org/${params.orgId}`, await authCookieHeader())
- .catch((e) => {})
- );
- const orgRes = await getOrg();
+ const orgRes = await getCachedOrg(params.orgId);
if (orgRes && orgRes.status === 200) {
org = orgRes.data.data;
diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx
index 6c39041c..b3e731e8 100644
--- a/src/app/[orgId]/settings/clients/machine/page.tsx
+++ b/src/app/[orgId]/settings/clients/machine/page.tsx
@@ -61,7 +61,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
niceId: client.niceId,
agent: client.agent,
archived: client.archived || false,
- blocked: client.blocked || false
+ blocked: client.blocked || false,
+ approvalState: client.approvalState ?? "approved"
};
};
diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx
index 79f9d800..dee24532 100644
--- a/src/app/[orgId]/settings/clients/user/page.tsx
+++ b/src/app/[orgId]/settings/clients/user/page.tsx
@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import { getTranslations } from "next-intl/server";
-import type { ClientRow } from "@app/components/MachineClientsTable";
+import type { ClientRow } from "@app/components/UserDevicesTable";
import UserDevicesTable from "@app/components/UserDevicesTable";
type ClientsPageProps = {
@@ -57,7 +57,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
niceId: client.niceId,
agent: client.agent,
archived: client.archived || false,
- blocked: client.blocked || false
+ blocked: client.blocked || false,
+ approvalState: client.approvalState
};
};
diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx
index f021076f..b26d79a9 100644
--- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx
@@ -768,6 +768,8 @@ export default function ResourceAuthenticationPage() {
>
@@ -777,11 +779,16 @@ export default function ResourceAuthenticationPage() {
type OneTimePasswordFormSectionProps = Pick<
ResourceContextType,
"resource" | "updateResource"
->;
+> & {
+ whitelist: Array<{ email: string }>;
+ isLoadingWhiteList: boolean;
+};
function OneTimePasswordFormSection({
resource,
- updateResource
+ updateResource,
+ whitelist,
+ isLoadingWhiteList
}: OneTimePasswordFormSectionProps) {
const { env } = useEnvContext();
const [whitelistEnabled, setWhitelistEnabled] = useState(
@@ -802,6 +809,18 @@ function OneTimePasswordFormSection({
number | null
>(null);
+ useEffect(() => {
+ if (isLoadingWhiteList) return;
+
+ whitelistForm.setValue(
+ "emails",
+ whitelist.map((w) => ({
+ id: w.email,
+ text: w.email
+ }))
+ );
+ }, [isLoadingWhiteList, whitelist, whitelistForm]);
+
async function saveWhitelist() {
try {
await api.post(`/resource/${resource.resourceId}`, {
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx
index 132f0c05..85f0e2b1 100644
--- a/src/app/[orgId]/settings/sites/page.tsx
+++ b/src/app/[orgId]/settings/sites/page.tsx
@@ -2,10 +2,9 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
-import SitesTable, { SiteRow } from "../../../../components/SitesTable";
+import SitesTable, { SiteRow } from "@app/components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesBanner from "@app/components/SitesBanner";
-import SitesSplashCard from "../../../../components/SitesSplashCard";
import { getTranslations } from "next-intl/server";
type SitesPageProps = {
diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx
index fae271f5..997ca3fb 100644
--- a/src/app/auth/layout.tsx
+++ b/src/app/auth/layout.tsx
@@ -113,7 +113,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
- {t("terms")}
+ {t("termsOfService")}
- {t("privacy")}
+ {t("privacyPolicy")}
>
)}
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index 0c9faafc..071020cd 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -1,19 +1,22 @@
import { verifySession } from "@app/lib/auth/verifySession";
import Link from "next/link";
import { redirect } from "next/navigation";
+import OrgSignInLink from "@app/components/OrgSignInLink";
import { cache } from "react";
+import SmartLoginForm from "@app/components/SmartLoginForm";
import DashboardLoginForm from "@app/components/DashboardLoginForm";
import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
-import { idp } from "@server/db";
-import { LoginFormIDP } from "@app/components/LoginForm";
-import { priv } from "@app/lib/api";
-import { AxiosResponse } from "axios";
-import { ListIdpsResponse } from "@server/routers/idp";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
+import { Card, CardContent } from "@app/components/ui/card";
+import LoginCardHeader from "@app/components/LoginCardHeader";
+import { priv } from "@app/lib/api";
+import { AxiosResponse } from "axios";
+import { LoginFormIDP } from "@app/components/LoginForm";
+import { ListIdpsResponse } from "@server/routers/idp";
export const dynamic = "force-dynamic";
@@ -69,22 +72,57 @@ export default async function Page(props: {
searchParams.redirect = redirectUrl;
}
+ // Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled)
+ const useSmartLogin =
+ build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp);
+
let loginIdps: LoginFormIDP[] = [];
- if (build === "oss" || !env.flags.useOrgOnlyIdp) {
- const idpsRes = await cache(
- async () => await priv.get>("/idp")
- )();
- loginIdps = idpsRes.data.data.idps.map((idp) => ({
- idpId: idp.idpId,
- name: idp.name,
- variant: idp.type
- })) as LoginFormIDP[];
+ if (!useSmartLogin) {
+ // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
+ if (build === "oss" || !env.flags.useOrgOnlyIdp) {
+ const idpsRes = await cache(
+ async () =>
+ await priv.get>("/idp")
+ )();
+ loginIdps = idpsRes.data.data.idps.map((idp) => ({
+ idpId: idp.idpId,
+ name: idp.name,
+ variant: idp.type
+ })) as LoginFormIDP[];
+ }
}
const t = await getTranslations();
return (
<>
+ {build === "saas" && (
+
+ {t.rich("loginLegalDisclaimer", {
+ termsOfService: (chunks) => (
+
+ {chunks}
+
+ ),
+ privacyPolicy: (chunks) => (
+
+ {chunks}
+
+ )
+ })}
+
+ )}
+
{isInvite && (
@@ -99,15 +137,36 @@ export default async function Page(props: {
)}
-
+ {useSmartLogin ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
{(!signUpDisabled || isInvite) && (
@@ -124,6 +183,31 @@ export default async function Page(props: {
)}
+
+ {!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? (
+
+ ) : null}
>
);
}
+
+function buildQueryString(searchParams: {
+ [key: string]: string | string[] | undefined;
+}): string {
+ const params = new URLSearchParams();
+ const redirect = searchParams.redirect;
+ const forceLogin = searchParams.forceLogin;
+
+ if (redirect && typeof redirect === "string") {
+ params.set("redirect", redirect);
+ }
+ if (forceLogin && typeof forceLogin === "string") {
+ params.set("forceLogin", forceLogin);
+ }
+ const queryString = params.toString();
+ return queryString ? `?${queryString}` : "";
+}
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx
index b4f4fddd..9ab7b7e6 100644
--- a/src/app/auth/signup/page.tsx
+++ b/src/app/auth/signup/page.tsx
@@ -14,6 +14,7 @@ export default async function Page(props: {
searchParams: Promise<{
redirect: string | undefined;
email: string | undefined;
+ fromSmartLogin: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
@@ -73,6 +74,7 @@ export default async function Page(props: {
inviteToken={inviteToken}
inviteId={inviteId}
emailParam={searchParams.email}
+ fromSmartLogin={searchParams.fromSmartLogin === "true"}
/>
diff --git a/src/app/globals.css b/src/app/globals.css
index 731e1bff..bbb165c2 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -21,6 +21,7 @@
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.91 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
@@ -55,6 +56,7 @@
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
+ --destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 13%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index 345b5a4c..4fb5430c 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -2,27 +2,27 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
- Settings,
- Users,
- Link as LinkIcon,
- Waypoints,
+ ChartLine,
Combine,
+ CreditCard,
Fingerprint,
+ Globe,
+ GlobeLock,
KeyRound,
+ Laptop,
+ Link as LinkIcon,
+ Logs, // Added from 'dev' branch
+ MonitorUp,
+ ReceiptText,
+ ScanEye, // Added from 'dev' branch
+ Server,
+ Settings,
+ SquareMousePointer,
TicketCheck,
User,
- Globe, // Added from 'dev' branch
- MonitorUp, // Added from 'dev' branch
- Server,
- ReceiptText,
- CreditCard,
- Logs,
- SquareMousePointer,
- ScanEye,
- GlobeLock,
- Smartphone,
- Laptop,
- ChartLine
+ UserCog,
+ Users,
+ Waypoints
} from "lucide-react";
export type SidebarNavSection = {
@@ -123,7 +123,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
href: "/{orgId}/settings/access/roles",
icon:
},
- ...(build == "saas" || env?.flags.useOrgOnlyIdp
+ ...(build === "saas" || env?.flags.useOrgOnlyIdp
? [
{
title: "sidebarIdentityProviders",
@@ -132,6 +132,15 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
}
]
: []),
+ ...(build !== "oss"
+ ? [
+ {
+ title: "sidebarApprovals",
+ href: "/{orgId}/settings/access/approvals",
+ icon:
+ }
+ ]
+ : []),
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx
new file mode 100644
index 00000000..b3d446b1
--- /dev/null
+++ b/src/components/ApprovalFeed.tsx
@@ -0,0 +1,243 @@
+"use client";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { cn } from "@app/lib/cn";
+import {
+ approvalFiltersSchema,
+ approvalQueries,
+ type ApprovalItem
+} from "@app/lib/queries";
+import { useQuery } from "@tanstack/react-query";
+import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Fragment, useActionState } from "react";
+import { Badge } from "./ui/badge";
+import { Button } from "./ui/button";
+import { Card, CardHeader } from "./ui/card";
+import { Label } from "./ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from "./ui/select";
+import { Separator } from "./ui/separator";
+
+export type ApprovalFeedProps = {
+ orgId: string;
+};
+
+export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
+ const searchParams = useSearchParams();
+ const path = usePathname();
+ const t = useTranslations();
+
+ const router = useRouter();
+
+ const filters = approvalFiltersSchema.parse(
+ Object.fromEntries(searchParams.entries())
+ );
+
+ const { data, isFetching, refetch } = useQuery(
+ approvalQueries.listApprovals(orgId, filters)
+ );
+
+ const approvals = data?.approvals ?? [];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {approvals.map((approval, index) => (
+
+ -
+ refetch()}
+ />
+
+ {index < approvals.length - 1 && }
+
+ ))}
+
+ {approvals.length === 0 && (
+ -
+ {t("approvalListEmpty")}
+
+ )}
+
+
+
+
+ );
+}
+
+type ApprovalRequestProps = {
+ approval: ApprovalItem;
+ orgId: string;
+ onSuccess?: () => void;
+};
+
+function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
+ const t = useTranslations();
+
+ const [_, formAction, isSubmitting] = useActionState(onSubmit, null);
+ const api = createApiClient(useEnvContext());
+
+ async function onSubmit(_previousState: any, formData: FormData) {
+ const decision = formData.get("decision");
+ const res = await api
+ .put(`/org/${orgId}/approvals/${approval.approvalId}`, { decision })
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: t("accessApprovalErrorUpdate"),
+ description: formatAxiosError(
+ e,
+ t("accessApprovalErrorUpdateDescription")
+ )
+ });
+ });
+ if (res && res.status === 200) {
+ const result = res.data.data;
+ toast({
+ variant: "default",
+ title: t("accessApprovalUpdated"),
+ description:
+ result.decision === "approved"
+ ? t("accessApprovalApprovedDescription")
+ : t("accessApprovalDeniedDescription")
+ });
+
+ onSuccess?.();
+ }
+ }
+
+ return (
+
+
+
+
+
+ {approval.user.username}
+
+
+ {approval.type === "user_device" && (
+ {t("requestingNewDeviceApproval")}
+ )}
+
+
+
+ {approval.decision === "pending" && (
+
+ )}
+ {approval.decision === "approved" && (
+
{t("approved")}
+ )}
+ {approval.decision === "denied" && (
+
{t("denied")}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx
index b8df1f78..8108461d 100644
--- a/src/components/CreateRoleForm.tsx
+++ b/src/components/CreateRoleForm.tsx
@@ -1,21 +1,5 @@
"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 { toast } from "@app/hooks/useToast";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { AxiosResponse } from "axios";
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
import {
Credenza,
CredenzaBody,
@@ -26,17 +10,37 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
-import { useOrgContext } from "@app/hooks/useOrgContext";
-import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
-import { formatAxiosError } from "@app/lib/api";
-import { createApiClient } from "@app/lib/api";
+import { Button } from "@app/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { build } from "@server/build";
+import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
+import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
+import { useTransition } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
+import { CheckboxWithLabel } from "./ui/checkbox";
type CreateRoleFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
- afterCreate?: (res: CreateRoleResponse) => Promise
;
+ afterCreate?: (res: CreateRoleResponse) => void;
};
export default function CreateRoleForm({
@@ -46,35 +50,35 @@ export default function CreateRoleForm({
}: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
+ const { isPaidUser } = usePaidStatus();
const formSchema = z.object({
- name: z.string({ message: t("nameRequired") }).max(32),
- description: z.string().max(255).optional()
+ name: z
+ .string({ message: t("nameRequired") })
+ .min(1)
+ .max(32),
+ description: z.string().max(255).optional(),
+ requireDeviceApproval: z.boolean().optional()
});
- const [loading, setLoading] = useState(false);
-
const api = createApiClient(useEnvContext());
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
- description: ""
+ description: "",
+ requireDeviceApproval: false
}
});
- async function onSubmit(values: z.infer) {
- setLoading(true);
+ const [loading, startTransition] = useTransition();
+ async function onSubmit(values: z.infer) {
const res = await api
- .put>(
- `/org/${org?.org.orgId}/role`,
- {
- name: values.name,
- description: values.description
- } as CreateRoleBody
- )
+ .put<
+ AxiosResponse
+ >(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
.catch((e) => {
toast({
variant: "destructive",
@@ -97,12 +101,8 @@ export default function CreateRoleForm({
setOpen(false);
}
- if (afterCreate) {
- afterCreate(res.data.data);
- }
+ afterCreate?.(res.data.data);
}
-
- setLoading(false);
}
return (
@@ -111,7 +111,6 @@ export default function CreateRoleForm({
open={open}
onOpenChange={(val) => {
setOpen(val);
- setLoading(false);
form.reset();
}}
>
@@ -125,7 +124,9 @@ export default function CreateRoleForm({
diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx
index 0446500c..26e84e5d 100644
--- a/src/components/Credenza.tsx
+++ b/src/components/Credenza.tsx
@@ -2,8 +2,6 @@
import * as React from "react";
-import { cn } from "@app/lib/cn";
-import { useMediaQuery } from "@app/hooks/useMediaQuery";
import {
Dialog,
DialogClose,
@@ -14,16 +12,9 @@ import {
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger
-} from "@/components/ui/drawer";
+import { DrawerClose } from "@/components/ui/drawer";
+import { useMediaQuery } from "@app/hooks/useMediaQuery";
+import { cn } from "@app/lib/cn";
import {
Sheet,
SheetContent,
@@ -78,10 +69,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
-
+
{children}
);
@@ -172,14 +160,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const isDesktop = useMediaQuery(desktop);
- // const isDesktop = true;
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
{
export {
Credenza,
- CredenzaTrigger,
+ CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
+ CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
- CredenzaBody,
- CredenzaFooter
+ CredenzaTrigger
};
diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx
index 5082f00d..f57eb8b1 100644
--- a/src/components/DashboardLoginForm.tsx
+++ b/src/components/DashboardLoginForm.tsx
@@ -69,22 +69,6 @@ export default function DashboardLoginForm({
- {showOrgLogin && (
-
-
-
-
-
- )}
);
}
-
-function buildQueryString(searchParams: {
- [key: string]: string | string[] | undefined;
-}): string {
- const params = new URLSearchParams();
- const redirect = searchParams.redirect;
- const forceLogin = searchParams.forceLogin;
-
- if (redirect && typeof redirect === "string") {
- params.set("redirect", redirect);
- }
- if (forceLogin && typeof forceLogin === "string") {
- params.set("forceLogin", forceLogin);
- }
- const queryString = params.toString();
- return queryString ? `?${queryString}` : "";
-}
diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx
index 777c1c03..914b43b0 100644
--- a/src/components/DeviceLoginForm.tsx
+++ b/src/components/DeviceLoginForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
@@ -13,7 +13,13 @@ import {
FormLabel,
FormMessage
} from "@/components/ui/form";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription
+} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -25,12 +31,12 @@ import {
InputOTPSlot
} from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
-import { AlertTriangle } from "lucide-react";
+import { AlertTriangle, Loader2 } from "lucide-react";
import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import BrandingLogo from "./BrandingLogo";
import { useTranslations } from "next-intl";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import UserProfileCard from "@/components/UserProfileCard";
const createFormSchema = (t: (key: string) => string) =>
z.object({
@@ -61,6 +67,8 @@ export default function DeviceLoginForm({
const api = createApiClient({ env });
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
+ const [validatingInitialCode, setValidatingInitialCode] = useState(false);
+ const [verifyingInitialCode, setVerifyingInitialCode] = useState(false);
const [metadata, setMetadata] = useState(null);
const [code, setCode] = useState("");
const { isUnlocked } = useLicenseStatusContext();
@@ -75,39 +83,88 @@ export default function DeviceLoginForm({
}
});
- async function onSubmit(data: z.infer) {
- setError(null);
- setLoading(true);
+ const validateCode = useCallback(
+ async (codeToValidate: string, skipConfirmation = false) => {
+ setError(null);
+ setLoading(true);
- try {
- // split code and add dash if missing
- if (!data.code.includes("-") && data.code.length === 8) {
- data.code = data.code.slice(0, 4) + "-" + data.code.slice(4);
- }
-
- // First check - get metadata
- const res = await api.post(
- "/device-web-auth/verify?forceLogin=true",
- {
- code: data.code.toUpperCase(),
- verify: false
+ try {
+ // split code and add dash if missing
+ let formattedCode = codeToValidate;
+ if (
+ !formattedCode.includes("-") &&
+ formattedCode.length === 8
+ ) {
+ formattedCode =
+ formattedCode.slice(0, 4) +
+ "-" +
+ formattedCode.slice(4);
}
- );
- if (res.data.success && res.data.data.metadata) {
- setMetadata(res.data.data.metadata);
- setCode(data.code.toUpperCase());
- } else {
- setError(t("deviceCodeInvalidOrExpired"));
+ // First check - get metadata
+ const res = await api.post(
+ "/device-web-auth/verify?forceLogin=true",
+ {
+ code: formattedCode.toUpperCase(),
+ verify: false
+ }
+ );
+
+ if (res.data.success && res.data.data.metadata) {
+ setCode(formattedCode.toUpperCase());
+
+ // If skipping confirmation (initial code), go straight to verify
+ if (skipConfirmation) {
+ setVerifyingInitialCode(true);
+ try {
+ await api.post("/device-web-auth/verify", {
+ code: formattedCode.toUpperCase(),
+ verify: true
+ });
+ router.push("/auth/login/device/success");
+ } catch (e: any) {
+ const errorMessage = formatAxiosError(e);
+ setError(
+ errorMessage || t("deviceCodeVerifyFailed")
+ );
+ setVerifyingInitialCode(false);
+ return false;
+ }
+ return true;
+ } else {
+ setMetadata(res.data.data.metadata);
+ return true;
+ }
+ } else {
+ setError(t("deviceCodeInvalidOrExpired"));
+ return false;
+ }
+ } catch (e: any) {
+ const errorMessage = formatAxiosError(e);
+ setError(errorMessage || t("deviceCodeInvalidOrExpired"));
+ return false;
+ } finally {
+ setLoading(false);
}
- } catch (e: any) {
- const errorMessage = formatAxiosError(e);
- setError(errorMessage || t("deviceCodeInvalidOrExpired"));
- } finally {
- setLoading(false);
- }
+ },
+ [api, t, router]
+ );
+
+ async function onSubmit(data: z.infer) {
+ await validateCode(data.code);
}
+ // Auto-validate initial code if provided
+ useEffect(() => {
+ const cleanedInitialCode = initialCode.replace(/-/g, "").toUpperCase();
+ if (cleanedInitialCode && cleanedInitialCode.length === 8) {
+ setValidatingInitialCode(true);
+ validateCode(cleanedInitialCode, false).finally(() => {
+ setValidatingInitialCode(false);
+ });
+ }
+ }, [initialCode, validateCode]);
+
async function onConfirm() {
if (!code || !metadata) return;
@@ -149,9 +206,6 @@ export default function DeviceLoginForm({
}
const profileLabel = (userName || userEmail || "").trim();
- const profileInitial = profileLabel
- ? profileLabel.charAt(0).toUpperCase()
- : "?";
async function handleUseDifferentAccount() {
try {
@@ -172,6 +226,39 @@ export default function DeviceLoginForm({
}
}
+ // Show loading state while validating/verifying initial code
+ if (validatingInitialCode || verifyingInitialCode) {
+ return (
+
+
+
+ {t("deviceActivation")}
+
+ {validatingInitialCode
+ ? t("deviceCodeValidating")
+ : t("deviceCodeVerifying")}
+
+
+
+
+
+
+ {validatingInitialCode
+ ? t("deviceCodeValidating")
+ : t("deviceCodeVerifying")}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+ }
+
if (metadata) {
return (
-
-
-
- {profileInitial}
-
-
-
-
- {profileLabel || userEmail}
-
-
- {t(
- "deviceLoginDeviceRequestingAccessToAccount"
- )}
-
-
-
-
-
+
+
{children && (
-
+
{children}
)}
diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx
new file mode 100644
index 00000000..7990ab92
--- /dev/null
+++ b/src/components/EditRoleForm.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import { Button } from "@app/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Input } from "@app/components/ui/input";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { build } from "@server/build";
+import type { Role } from "@server/db";
+import type {
+ CreateRoleBody,
+ CreateRoleResponse,
+ UpdateRoleBody,
+ UpdateRoleResponse
+} from "@server/routers/role";
+import { AxiosResponse } from "axios";
+import { useTranslations } from "next-intl";
+import { useTransition } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
+import { CheckboxWithLabel } from "./ui/checkbox";
+
+type CreateRoleFormProps = {
+ role: Role;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ onSuccess?: (res: CreateRoleResponse) => void;
+};
+
+export default function EditRoleForm({
+ open,
+ role,
+ setOpen,
+ onSuccess
+}: CreateRoleFormProps) {
+ const { org } = useOrgContext();
+ const t = useTranslations();
+ const { isPaidUser } = usePaidStatus();
+
+ const formSchema = z.object({
+ name: z
+ .string({ message: t("nameRequired") })
+ .min(1)
+ .max(32),
+ description: z.string().max(255).optional(),
+ requireDeviceApproval: z.boolean().optional()
+ });
+
+ const api = createApiClient(useEnvContext());
+
+ const form = useForm
>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: role.name,
+ description: role.description ?? "",
+ requireDeviceApproval: role.requireDeviceApproval ?? false
+ }
+ });
+
+ const [loading, startTransition] = useTransition();
+
+ async function onSubmit(values: z.infer) {
+ const res = await api
+ .post<
+ AxiosResponse
+ >(`/org/${org?.org.orgId}/role/${role.roleId}`, values satisfies UpdateRoleBody)
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: t("accessRoleErrorUpdate"),
+ description: formatAxiosError(
+ e,
+ t("accessRoleErrorUpdateDescription")
+ )
+ });
+ });
+
+ if (res && res.status === 200) {
+ toast({
+ variant: "default",
+ title: t("accessRoleUpdated"),
+ description: t("accessRoleUpdatedDescription")
+ });
+
+ if (open) {
+ setOpen(false);
+ }
+
+ onSuccess?.(res.data.data);
+ }
+ }
+
+ return (
+ <>
+ {
+ setOpen(val);
+ form.reset();
+ }}
+ >
+
+
+ {t("accessRoleEdit")}
+
+ {t("accessRoleEditDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/LoginCardHeader.tsx b/src/components/LoginCardHeader.tsx
new file mode 100644
index 00000000..5087b869
--- /dev/null
+++ b/src/components/LoginCardHeader.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import BrandingLogo from "@app/components/BrandingLogo";
+import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { CardHeader } from "./ui/card";
+
+type LoginCardHeaderProps = {
+ subtitle: string;
+};
+
+export default function LoginCardHeader({ subtitle }: LoginCardHeaderProps) {
+ const { env } = useEnvContext();
+ const { isUnlocked } = useLicenseStatusContext();
+
+ const logoWidth = isUnlocked()
+ ? env.branding.logo?.authPage?.width || 175
+ : 175;
+ const logoHeight = isUnlocked()
+ ? env.branding.logo?.authPage?.height || 58
+ : 58;
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx
index 49bcc69b..5497826c 100644
--- a/src/components/LoginForm.tsx
+++ b/src/components/LoginForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState, useRef } from "react";
+import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
@@ -23,32 +23,24 @@ import {
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useParams, useRouter } from "next/navigation";
-import { LockIcon, FingerprintIcon } from "lucide-react";
+import { LockIcon } from "lucide-react";
+import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { createApiClient } from "@app/lib/api";
-import {
- InputOTP,
- InputOTPGroup,
- InputOTPSeparator,
- InputOTPSlot
-} from "./ui/input-otp";
import Link from "next/link";
-import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
-import { startAuthentication } from "@simplewebauthn/browser";
import {
generateOidcUrlProxy,
- loginProxy,
- securityKeyStartProxy,
- securityKeyVerifyProxy
+ loginProxy
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
// @ts-ignore
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
+import MfaInputForm from "@app/components/MfaInputForm";
export type LoginFormIDP = {
idpId: number;
@@ -83,8 +75,6 @@ export default function LoginForm({
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false);
- const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
- const otpContainerRef = useRef(null);
const t = useTranslations();
const currentHost =
@@ -113,52 +103,6 @@ export default function LoginForm({
}
}, []);
- // Auto-focus MFA input when MFA is requested
- useEffect(() => {
- if (!mfaRequested) return;
-
- const focusInput = () => {
- // Try using the ref first
- if (otpContainerRef.current) {
- const hiddenInput = otpContainerRef.current.querySelector(
- "input"
- ) as HTMLInputElement;
- if (hiddenInput) {
- hiddenInput.focus();
- return;
- }
- }
-
- // Fallback: query the DOM
- const otpContainer = document.querySelector(
- '[data-slot="input-otp"]'
- );
- if (!otpContainer) return;
-
- const hiddenInput = otpContainer.querySelector(
- "input"
- ) as HTMLInputElement;
- if (hiddenInput) {
- hiddenInput.focus();
- return;
- }
-
- // Last resort: click the first slot
- const firstSlot = otpContainer.querySelector(
- '[data-slot="input-otp-slot"]'
- ) as HTMLElement;
- if (firstSlot) {
- firstSlot.click();
- }
- };
-
- // Use requestAnimationFrame to wait for the next paint
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- focusInput();
- });
- });
- }, [mfaRequested]);
const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
@@ -184,97 +128,6 @@ export default function LoginForm({
}
});
- async function initiateSecurityKeyAuth() {
- setShowSecurityKeyPrompt(true);
- setLoading(true);
- setError(null);
-
- try {
- // Start WebAuthn authentication without email
- const startResponse = await securityKeyStartProxy({}, forceLogin);
-
- if (startResponse.error) {
- setError(startResponse.message);
- return;
- }
-
- const { tempSessionId, ...options } = startResponse.data!;
-
- // Perform WebAuthn authentication
- try {
- const credential = await startAuthentication({
- optionsJSON: {
- ...options,
- userVerification: options.userVerification as
- | "required"
- | "preferred"
- | "discouraged"
- }
- });
-
- // Verify authentication
- const verifyResponse = await securityKeyVerifyProxy(
- { credential },
- tempSessionId,
- forceLogin
- );
-
- if (verifyResponse.error) {
- setError(verifyResponse.message);
- return;
- }
-
- if (verifyResponse.success) {
- if (onLogin) {
- await onLogin(redirect);
- }
- }
- } catch (error: any) {
- if (error.name === "NotAllowedError") {
- if (error.message.includes("denied permission")) {
- setError(
- t("securityKeyPermissionDenied", {
- defaultValue:
- "Please allow access to your security key to continue signing in."
- })
- );
- } else {
- setError(
- t("securityKeyRemovedTooQuickly", {
- defaultValue:
- "Please keep your security key connected until the sign-in process completes."
- })
- );
- }
- } else if (error.name === "NotSupportedError") {
- setError(
- t("securityKeyNotSupported", {
- defaultValue:
- "Your security key may not be compatible. Please try a different security key."
- })
- );
- } else {
- setError(
- t("securityKeyUnknownError", {
- defaultValue:
- "There was a problem using your security key. Please try again."
- })
- );
- }
- }
- } catch (e: any) {
- console.error(e);
- setError(
- t("securityKeyAuthError", {
- defaultValue:
- "An unexpected error occurred. Please try again."
- })
- );
- } finally {
- setLoading(false);
- setShowSecurityKeyPrompt(false);
- }
- }
async function onSubmit(values: any) {
const { email, password } = form.getValues();
@@ -282,7 +135,6 @@ export default function LoginForm({
setLoading(true);
setError(null);
- setShowSecurityKeyPrompt(false);
try {
const response = await loginProxy(
@@ -323,7 +175,12 @@ export default function LoginForm({
}
if (data.useSecurityKey) {
- await initiateSecurityKeyAuth();
+ setError(
+ t("securityKeyRequired", {
+ defaultValue:
+ "Please use your security key to sign in."
+ })
+ );
return;
}
@@ -409,18 +266,6 @@ export default function LoginForm({
return (
- {showSecurityKeyPrompt && (
-
-
-
- {t("securityKeyPrompt", {
- defaultValue:
- "Please verify your identity using your security key. Make sure your security key is connected and ready."
- })}
-
-
- )}
-
{!mfaRequested && (
<>
-
- >
+
{
+ setMfaRequested(false);
+ mfaForm.reset();
+ }}
+ error={error}
+ loading={loading}
+ formId="form"
+ />
)}
- {error && (
+ {!mfaRequested && error && (
{error}
)}
- {mfaRequested && (
-
- )}
{!mfaRequested && (
<>
-
+
{hasIdp && (
<>
@@ -652,19 +418,6 @@ export default function LoginForm({
>
)}
- {mfaRequested && (
-
- )}
);
diff --git a/src/components/LoginOrgSelector.tsx b/src/components/LoginOrgSelector.tsx
new file mode 100644
index 00000000..a7b52414
--- /dev/null
+++ b/src/components/LoginOrgSelector.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@app/components/ui/button";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import { useTranslations } from "next-intl";
+import { Separator } from "./ui/separator";
+import LoginPasswordForm from "./LoginPasswordForm";
+import IdpLoginButtons from "./private/IdpLoginButtons";
+import { LookupUserResponse } from "@server/routers/auth/lookupUser";
+import UserProfileCard from "./UserProfileCard";
+
+type LoginOrgSelectorProps = {
+ identifier: string;
+ lookupResult: LookupUserResponse;
+ redirect?: string;
+ forceLogin?: boolean;
+ onUseDifferentAccount?: () => void;
+};
+
+export default function LoginOrgSelector({
+ identifier,
+ lookupResult,
+ redirect,
+ forceLogin,
+ onUseDifferentAccount
+}: LoginOrgSelectorProps) {
+ const t = useTranslations();
+ const [showPasswordForm, setShowPasswordForm] = useState(false);
+
+ // Collect all unique orgs from all accounts
+ const orgMap = new Map<
+ string,
+ {
+ orgId: string;
+ orgName: string;
+ idps: Array<{
+ idpId: number;
+ name: string;
+ variant: string | null;
+ }>;
+ hasInternalAuth: boolean;
+ }
+ >();
+
+ for (const account of lookupResult.accounts) {
+ for (const org of account.orgs) {
+ if (!orgMap.has(org.orgId)) {
+ orgMap.set(org.orgId, {
+ orgId: org.orgId,
+ orgName: org.orgName,
+ idps: org.idps,
+ hasInternalAuth: org.hasInternalAuth
+ });
+ } else {
+ // Merge IdPs if org appears in multiple accounts
+ const existing = orgMap.get(org.orgId)!;
+ const existingIdpIds = new Set(
+ existing.idps.map((i) => i.idpId)
+ );
+ for (const idp of org.idps) {
+ if (!existingIdpIds.has(idp.idpId)) {
+ existing.idps.push(idp);
+ }
+ }
+ if (org.hasInternalAuth) {
+ existing.hasInternalAuth = true;
+ }
+ }
+ }
+ }
+
+ const orgs = Array.from(orgMap.values());
+
+ // Check if there's an internal account (can only be one)
+ const hasInternalAccount = lookupResult.accounts.some(
+ (acc) => acc.hasInternalAuth
+ );
+
+ // If user selected password auth, show password form
+ if (showPasswordForm) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {hasInternalAccount && (
+
+
+
+ )}
+
+
+ {orgs.map((org, index) => {
+ const hasIdps = org.idps.length > 0;
+
+ if (!hasIdps) {
+ return null;
+ }
+
+ // Convert org.idps to LoginFormIDP format
+ const idps = org.idps.map((idp) => ({
+ idpId: idp.idpId,
+ name: idp.name,
+ variant: idp.variant || undefined
+ }));
+
+ return (
+
+
+
+ {org.orgName}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/LoginPasswordForm.tsx b/src/components/LoginPasswordForm.tsx
new file mode 100644
index 00000000..b2cb3175
--- /dev/null
+++ b/src/components/LoginPasswordForm.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "@app/components/ui/button";
+import { Input } from "@app/components/ui/input";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { loginProxy } from "@app/actions/server";
+import Link from "next/link";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { cleanRedirect } from "@app/lib/cleanRedirect";
+import MfaInputForm from "@app/components/MfaInputForm";
+
+type LoginPasswordFormProps = {
+ identifier: string;
+ redirect?: string;
+ forceLogin?: boolean;
+};
+
+export default function LoginPasswordForm({
+ identifier,
+ redirect,
+ forceLogin
+}: LoginPasswordFormProps) {
+ const router = useRouter();
+ const { env } = useEnvContext();
+ const t = useTranslations();
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [mfaRequested, setMfaRequested] = useState(false);
+
+ // Check if identifier is a valid email
+ const isEmail = (() => {
+ try {
+ z.string().email().parse(identifier);
+ return true;
+ } catch {
+ return false;
+ }
+ })();
+
+ const currentHost =
+ typeof window !== "undefined" ? window.location.hostname : "";
+ const expectedHost = new URL(env.app.dashboardUrl).host;
+ const isExpectedHost = currentHost === expectedHost;
+
+ const formSchema = z.object({
+ password: z.string().min(8, { message: t("passwordRequirementsChars") })
+ });
+
+ const mfaSchema = z.object({
+ code: z.string().length(6, { message: t("pincodeInvalid") })
+ });
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ password: ""
+ }
+ });
+
+ const mfaForm = useForm({
+ resolver: zodResolver(mfaSchema),
+ defaultValues: {
+ code: ""
+ }
+ });
+
+ async function onSubmit(values: z.infer) {
+ const { password } = values;
+ const { code } = mfaForm.getValues();
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await loginProxy(
+ {
+ email: identifier,
+ password,
+ code,
+ resourceGuid: undefined
+ },
+ forceLogin
+ );
+
+ if (response.error) {
+ setError(response.message);
+ return;
+ }
+
+ const data = response.data;
+
+ if (!data) {
+ // Already logged in
+ if (redirect) {
+ const safe = cleanRedirect(redirect);
+ router.replace(safe);
+ } else {
+ router.replace("/");
+ }
+ return;
+ }
+
+ if (data.useSecurityKey) {
+ setError(t("securityKeyRequired"));
+ return;
+ }
+
+ if (data.codeRequested) {
+ setMfaRequested(true);
+ setLoading(false);
+ mfaForm.reset();
+ return;
+ }
+
+ if (data.emailVerificationRequired) {
+ if (!isExpectedHost) {
+ setError(
+ t("emailVerificationRequired", {
+ dashboardUrl: env.app.dashboardUrl
+ })
+ );
+ return;
+ }
+ if (redirect) {
+ router.push(`/auth/verify-email?redirect=${redirect}`);
+ } else {
+ router.push("/auth/verify-email");
+ }
+ return;
+ }
+
+ if (data.twoFactorSetupRequired) {
+ if (!isExpectedHost) {
+ setError(
+ t("twoFactorSetupRequired", {
+ dashboardUrl: env.app.dashboardUrl
+ })
+ );
+ return;
+ }
+ const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(identifier)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
+ router.push(setupUrl);
+ return;
+ }
+
+ // Success
+ if (redirect) {
+ const safe = cleanRedirect(redirect);
+ router.replace(safe);
+ } else {
+ router.replace("/");
+ }
+ } catch (e: any) {
+ console.error(e);
+ setError(t("loginError"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function onMfaSubmit(values: z.infer) {
+ const { password } = form.getValues();
+ const { code } = values;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await loginProxy(
+ {
+ email: identifier,
+ password,
+ code,
+ resourceGuid: undefined
+ },
+ forceLogin
+ );
+
+ if (response.error) {
+ setError(response.message);
+ setLoading(false);
+ return;
+ }
+
+ const data = response.data;
+
+ if (!data) {
+ if (redirect) {
+ const safe = cleanRedirect(redirect);
+ router.replace(safe);
+ } else {
+ router.replace("/");
+ }
+ return;
+ }
+
+ if (data.emailVerificationRequired) {
+ if (!isExpectedHost) {
+ setError(
+ t("emailVerificationRequired", {
+ dashboardUrl: env.app.dashboardUrl
+ })
+ );
+ return;
+ }
+ if (redirect) {
+ router.push(`/auth/verify-email?redirect=${redirect}`);
+ } else {
+ router.push("/auth/verify-email");
+ }
+ return;
+ }
+
+ if (data.twoFactorSetupRequired) {
+ if (!isExpectedHost) {
+ setError(
+ t("twoFactorSetupRequired", {
+ dashboardUrl: env.app.dashboardUrl
+ })
+ );
+ return;
+ }
+ const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(identifier)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
+ router.push(setupUrl);
+ return;
+ }
+
+ // Success
+ if (redirect) {
+ const safe = cleanRedirect(redirect);
+ router.replace(safe);
+ } else {
+ router.replace("/");
+ }
+ } catch (e: any) {
+ console.error(e);
+ setError(t("loginError"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (mfaRequested) {
+ return (
+ {
+ setMfaRequested(false);
+ mfaForm.reset();
+ }}
+ error={error}
+ loading={loading}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx
index 85d09664..71117be6 100644
--- a/src/components/MachineClientsTable.tsx
+++ b/src/components/MachineClientsTable.tsx
@@ -1,9 +1,8 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
-import { DataTable } from "@app/components/ui/data-table";
-import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
+import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,7 +15,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ArrowRight,
ArrowUpDown,
- ArrowUpRight,
MoreHorizontal,
CircleSlash
} from "lucide-react";
@@ -25,7 +23,6 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
-import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
@@ -45,6 +42,7 @@ export type ClientRow = {
agent: string | null;
archived?: boolean;
blocked?: boolean;
+ approvalState: "approved" | "pending" | "denied";
};
type ClientTableProps = {
@@ -214,7 +212,10 @@ export default function MachineClientsTable({
)}
{r.blocked && (
-
+
{t("blocked")}
@@ -410,7 +411,9 @@ export default function MachineClientsTable({
}}
>
- {clientRow.archived ? "Unarchive" : "Archive"}
+ {clientRow.archived
+ ? "Unarchive"
+ : "Archive"}
- {clientRow.blocked ? "Unblock" : "Block"}
+ {clientRow.blocked
+ ? "Unblock"
+ : "Block"}
{
+ filterFn: (
+ row: ClientRow,
+ selectedValues: (string | number | boolean)[]
+ ) => {
if (selectedValues.length === 0) return true;
const rowArchived = row.archived || false;
const rowBlocked = row.blocked || false;
const isActive = !rowArchived && !rowBlocked;
-
- if (selectedValues.includes("active") && isActive) return true;
- if (selectedValues.includes("archived") && rowArchived) return true;
- if (selectedValues.includes("blocked") && rowBlocked) return true;
+
+ if (selectedValues.includes("active") && isActive)
+ return true;
+ if (
+ selectedValues.includes("archived") &&
+ rowArchived
+ )
+ return true;
+ if (
+ selectedValues.includes("blocked") &&
+ rowBlocked
+ )
+ return true;
return false;
},
defaultValues: ["active"] // Default to showing active clients
diff --git a/src/components/MfaInputForm.tsx b/src/components/MfaInputForm.tsx
new file mode 100644
index 00000000..d52b3169
--- /dev/null
+++ b/src/components/MfaInputForm.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { UseFormReturn } from "react-hook-form";
+import { Button } from "@app/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage
+} from "@app/components/ui/form";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot
+} from "./ui/input-otp";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import { useTranslations } from "next-intl";
+import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
+import * as z from "zod";
+
+type MfaInputFormProps = {
+ form: UseFormReturn<{ code: string }>;
+ onSubmit: (values: { code: string }) => void | Promise;
+ onBack: () => void;
+ error?: string | null;
+ loading?: boolean;
+ formId?: string;
+};
+
+export default function MfaInputForm({
+ form,
+ onSubmit,
+ onBack,
+ error,
+ loading = false,
+ formId = "mfaForm"
+}: MfaInputFormProps) {
+ const t = useTranslations();
+ const otpContainerRef = useRef(null);
+
+ // Auto-focus MFA input when component mounts
+ useEffect(() => {
+ const focusInput = () => {
+ // Try using the ref first
+ if (otpContainerRef.current) {
+ const hiddenInput = otpContainerRef.current.querySelector(
+ "input"
+ ) as HTMLInputElement;
+ if (hiddenInput) {
+ hiddenInput.focus();
+ return;
+ }
+ }
+
+ // Fallback: query the DOM
+ const otpContainer = document.querySelector(
+ '[data-slot="input-otp"]'
+ );
+ if (!otpContainer) return;
+
+ const hiddenInput = otpContainer.querySelector(
+ "input"
+ ) as HTMLInputElement;
+ if (hiddenInput) {
+ hiddenInput.focus();
+ return;
+ }
+
+ // Last resort: click the first slot
+ const firstSlot = otpContainer.querySelector(
+ '[data-slot="input-otp-slot"]'
+ ) as HTMLElement;
+ if (firstSlot) {
+ firstSlot.click();
+ }
+ };
+
+ // Use requestAnimationFrame to wait for the next paint
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ focusInput();
+ });
+ });
+ }, []);
+
+ return (
+
+
+
{t("otpAuth")}
+
+ {t("otpAuthDescription")}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/OrgLoginPage.tsx b/src/components/OrgLoginPage.tsx
index 5efb2a04..f2a8ae2a 100644
--- a/src/components/OrgLoginPage.tsx
+++ b/src/components/OrgLoginPage.tsx
@@ -116,6 +116,14 @@ export default async function OrgLoginPage({
)}
+
+
+ {t("loginBack")}
+
+
);
}
diff --git a/src/components/OrgSignInLink.tsx b/src/components/OrgSignInLink.tsx
new file mode 100644
index 00000000..819a1dc7
--- /dev/null
+++ b/src/components/OrgSignInLink.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
+import { Button } from "@app/components/ui/button";
+
+type OrgSignInLinkProps = {
+ href: string;
+ linkText: string;
+ descriptionText: string;
+};
+
+const STORAGE_KEY_CLICKED = "orgSignInLinkClicked";
+const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged";
+
+export default function OrgSignInLink({
+ href,
+ linkText,
+ descriptionText
+}: OrgSignInLinkProps) {
+ const router = useRouter();
+ const t = useTranslations();
+ const [showTip, setShowTip] = useState(false);
+
+ useEffect(() => {
+ // Check if tip was previously acknowledged
+ const acknowledged =
+ localStorage.getItem(STORAGE_KEY_ACKNOWLEDGED) === "true";
+ if (acknowledged) {
+ // Clear the clicked flag if tip was acknowledged
+ localStorage.removeItem(STORAGE_KEY_CLICKED);
+ }
+ }, []);
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+
+ const hasClickedBefore =
+ localStorage.getItem(STORAGE_KEY_CLICKED) === "true";
+ const isAcknowledged =
+ localStorage.getItem(STORAGE_KEY_ACKNOWLEDGED) === "true";
+
+ if (hasClickedBefore && !isAcknowledged) {
+ // Second click (or later) - show tip
+ setShowTip(true);
+ } else {
+ // First click - store flag and navigate
+ localStorage.setItem(STORAGE_KEY_CLICKED, "true");
+ router.push(href);
+ }
+ };
+
+ const handleContinueAnyway = () => {
+ setShowTip(false);
+ router.push(href);
+ };
+
+ const handleDontShowAgain = () => {
+ setShowTip(false);
+ localStorage.setItem(STORAGE_KEY_ACKNOWLEDGED, "true");
+ localStorage.removeItem(STORAGE_KEY_CLICKED);
+ };
+
+ return (
+ <>
+ {showTip && (
+
+ {t("orgSignInNotice")}
+
+ {t("orgSignInTip")}
+
+
+
+
+
+
+ )}
+
+ {descriptionText}
+
+
+ >
+ );
+}
diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx
index 6ecfbbac..210eee3c 100644
--- a/src/components/RolesTable.tsx
+++ b/src/components/RolesTable.tsx
@@ -1,27 +1,27 @@
"use client";
-import { ColumnDef } from "@tanstack/react-table";
-import { ExtendedColumnDef } from "@app/components/ui/data-table";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger
-} from "@app/components/ui/dropdown-menu";
-import { Button } from "@app/components/ui/button";
-import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
-import { useState } from "react";
-import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
-import { useOrgContext } from "@app/hooks/useOrgContext";
-import { toast } from "@app/hooks/useToast";
-import { RolesDataTable } from "@app/components/RolesDataTable";
-import { Role } from "@server/db";
import CreateRoleForm from "@app/components/CreateRoleForm";
import DeleteRoleForm from "@app/components/DeleteRoleForm";
-import { createApiClient } from "@app/lib/api";
+import { RolesDataTable } from "@app/components/RolesDataTable";
+import { Button } from "@app/components/ui/button";
+import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient } from "@app/lib/api";
+import { Role } from "@server/db";
+import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem
+} from "./ui/dropdown-menu";
+import EditRoleForm from "./EditRoleForm";
export type RoleRow = Role;
@@ -29,27 +29,26 @@ type RolesTableProps = {
roles: RoleRow[];
};
-export default function UsersTable({ roles: r }: RolesTableProps) {
+export default function UsersTable({ roles }: RolesTableProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [editingRole, setEditingRole] = useState(null);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const router = useRouter();
- const [roles, setRoles] = useState(r);
-
- const [roleToRemove, setUserToRemove] = useState(null);
+ const [roleToRemove, setRoleToRemove] = useState(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
+ const { isPaidUser } = usePaidStatus();
const t = useTranslations();
- const [isRefreshing, setIsRefreshing] = useState(false);
+ const [isRefreshing, startTransition] = useTransition();
const refreshData = async () => {
console.log("Data refreshed");
- setIsRefreshing(true);
try {
- await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
@@ -57,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
description: t("refreshError"),
variant: "destructive"
});
- } finally {
- setIsRefreshing(false);
}
};
@@ -86,26 +83,74 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
friendlyName: t("description"),
header: () => {t("description")}
},
+ // {
+ // id: "actions",
+ // enableHiding: false,
+ // header: () => ,
+ // cell: ({ row }) => {
+ // const roleRow = row.original;
+
+ // return (
+ //
+ //
+ //
+ // );
+ // }
+ // },
{
id: "actions",
enableHiding: false,
header: () => ,
cell: ({ row }) => {
const roleRow = row.original;
-
return (
-
-
-
+ !roleRow.isAdmin && (
+
+
+
+
+
+
+ {
+ setRoleToRemove(roleRow);
+ setIsDeleteModalOpen(true);
+ }}
+ >
+
+ {t("delete")}
+
+
+
+
+
+
+ )
);
}
}
@@ -113,11 +158,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return (
<>
+ {editingRole && (
+ {
+ // Delay refresh to allow modal to close smoothly
+ setTimeout(() => {
+ startTransition(async () => {
+ await refreshData().then(() =>
+ setEditingRole(null)
+ );
+ });
+ }, 150);
+ }}
+ />
+ )}
{
- setRoles((prev) => [...prev, role]);
+ afterCreate={() => {
+ startTransition(refreshData);
}}
/>
@@ -127,10 +190,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
setOpen={setIsDeleteModalOpen}
roleToDelete={roleToRemove}
afterDelete={() => {
- setRoles((prev) =>
- prev.filter((r) => r.roleId !== roleToRemove.roleId)
- );
- setUserToRemove(null);
+ startTransition(async () => {
+ await refreshData().then(() =>
+ setRoleToRemove(null)
+ );
+ });
}}
/>
)}
@@ -141,7 +205,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
createRole={() => {
setIsCreateModalOpen(true);
}}
- onRefresh={refreshData}
+ onRefresh={() => startTransition(refreshData)}
isRefreshing={isRefreshing}
/>
>
diff --git a/src/components/SecurityKeyAuthButton.tsx b/src/components/SecurityKeyAuthButton.tsx
new file mode 100644
index 00000000..184587fd
--- /dev/null
+++ b/src/components/SecurityKeyAuthButton.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@app/components/ui/button";
+import { FingerprintIcon } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { startAuthentication } from "@simplewebauthn/browser";
+import {
+ securityKeyStartProxy,
+ securityKeyVerifyProxy
+} from "@app/actions/server";
+import { useRouter } from "next/navigation";
+import { cleanRedirect } from "@app/lib/cleanRedirect";
+
+type SecurityKeyAuthButtonProps = {
+ redirect?: string;
+ forceLogin?: boolean;
+ onSuccess?: (redirectUrl?: string) => void | Promise;
+ onError?: (error: string) => void;
+ disabled?: boolean;
+ className?: string;
+};
+
+export default function SecurityKeyAuthButton({
+ redirect,
+ forceLogin,
+ onSuccess,
+ onError,
+ disabled: externalDisabled,
+ className
+}: SecurityKeyAuthButtonProps) {
+ const router = useRouter();
+ const t = useTranslations();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function initiateSecurityKeyAuth() {
+ setLoading(true);
+ setError(null);
+
+ try {
+ // Start WebAuthn authentication without email
+ const startResponse = await securityKeyStartProxy({}, forceLogin);
+
+ if (startResponse.error) {
+ const errorMessage = startResponse.message;
+ setError(errorMessage);
+ if (onError) {
+ onError(errorMessage);
+ }
+ setLoading(false);
+ return;
+ }
+
+ const { tempSessionId, ...options } = startResponse.data!;
+
+ // Perform WebAuthn authentication
+ try {
+ const credential = await startAuthentication({
+ optionsJSON: {
+ ...options,
+ userVerification: options.userVerification as
+ | "required"
+ | "preferred"
+ | "discouraged"
+ }
+ });
+
+ // Verify authentication
+ const verifyResponse = await securityKeyVerifyProxy(
+ { credential },
+ tempSessionId,
+ forceLogin
+ );
+
+ if (verifyResponse.error) {
+ const errorMessage = verifyResponse.message;
+ setError(errorMessage);
+ if (onError) {
+ onError(errorMessage);
+ }
+ setLoading(false);
+ return;
+ }
+
+ if (verifyResponse.success) {
+ if (onSuccess) {
+ await onSuccess(redirect);
+ } else {
+ // Default behavior: redirect
+ if (redirect) {
+ const safe = cleanRedirect(redirect);
+ router.replace(safe);
+ } else {
+ router.replace("/");
+ }
+ }
+ }
+ } catch (error: any) {
+ let errorMessage: string;
+ if (error.name === "NotAllowedError") {
+ if (error.message.includes("denied permission")) {
+ errorMessage = t("securityKeyPermissionDenied", {
+ defaultValue:
+ "Please allow access to your security key to continue signing in."
+ });
+ } else {
+ errorMessage = t("securityKeyRemovedTooQuickly", {
+ defaultValue:
+ "Please keep your security key connected until the sign-in process completes."
+ });
+ }
+ } else if (error.name === "NotSupportedError") {
+ errorMessage = t("securityKeyNotSupported", {
+ defaultValue:
+ "Your security key may not be compatible. Please try a different security key."
+ });
+ } else {
+ errorMessage = t("securityKeyUnknownError", {
+ defaultValue:
+ "There was a problem using your security key. Please try again."
+ });
+ }
+ setError(errorMessage);
+ if (onError) {
+ onError(errorMessage);
+ }
+ setLoading(false);
+ }
+ } catch (e: any) {
+ console.error(e);
+ const errorMessage = t("securityKeyAuthError", {
+ defaultValue:
+ "An unexpected error occurred. Please try again."
+ });
+ setError(errorMessage);
+ if (onError) {
+ onError(errorMessage);
+ }
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx
index 42364b06..f20856b4 100644
--- a/src/components/SignupForm.tsx
+++ b/src/components/SignupForm.tsx
@@ -16,7 +16,8 @@ import {
FormMessage
} from "@/components/ui/form";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import Link from "next/link";
import { Progress } from "@/components/ui/progress";
import { SignUpResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
@@ -70,6 +71,7 @@ type SignupFormProps = {
inviteId?: string;
inviteToken?: string;
emailParam?: string;
+ fromSmartLogin?: boolean;
};
const formSchema = z
@@ -100,7 +102,8 @@ export default function SignupForm({
redirect,
inviteId,
inviteToken,
- emailParam
+ emailParam,
+ fromSmartLogin = false
}: SignupFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -201,8 +204,28 @@ export default function SignupForm({
? env.branding.logo?.authPage?.height || 58
: 58;
+ const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp);
+ const orgBannerHref = redirect
+ ? `/auth/org?redirect=${encodeURIComponent(redirect)}`
+ : "/auth/org";
+
return (
-
+ <>
+ {showOrgBanner && (
+
+ {t("signupOrgNotice")}
+
+ {t("signupOrgTip")}
+
+ {t("signupOrgLink")}
+
+
+
+ )}
+
@@ -581,9 +604,10 @@ export default function SignupForm({
-
-
-
-
+
+
+
+
+ >
);
}
diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx
new file mode 100644
index 00000000..5e1498ff
--- /dev/null
+++ b/src/components/SmartLoginForm.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "@app/components/ui/button";
+import { Input } from "@app/components/ui/input";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import { useRouter } from "next/navigation";
+import { useUserLookup } from "@app/hooks/useUserLookup";
+import { LookupUserResponse } from "@server/routers/auth/lookupUser";
+import { useTranslations } from "next-intl";
+import LoginPasswordForm from "@app/components/LoginPasswordForm";
+import LoginOrgSelector from "@app/components/LoginOrgSelector";
+import UserProfileCard from "@app/components/UserProfileCard";
+import { ArrowLeft } from "lucide-react";
+import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
+
+const identifierSchema = z.object({
+ identifier: z.string().min(1, "Username or email is required")
+});
+
+// Helper to check if string is a valid email
+const isValidEmail = (str: string): boolean => {
+ try {
+ z.string().email().parse(str);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+type SmartLoginFormProps = {
+ redirect?: string;
+ forceLogin?: boolean;
+};
+
+type ViewState =
+ | { type: "initial" }
+ | {
+ type: "password";
+ identifier: string;
+ account: LookupUserResponse["accounts"][0];
+ }
+ | {
+ type: "orgSelector";
+ identifier: string;
+ lookupResult: LookupUserResponse;
+ };
+
+export default function SmartLoginForm({
+ redirect,
+ forceLogin
+}: SmartLoginFormProps) {
+ const router = useRouter();
+ const { lookup, loading, error } = useUserLookup();
+ const t = useTranslations();
+ const [viewState, setViewState] = useState
({ type: "initial" });
+ const [securityKeyError, setSecurityKeyError] = useState(
+ null
+ );
+
+ const form = useForm>({
+ resolver: zodResolver(identifierSchema),
+ defaultValues: {
+ identifier: ""
+ }
+ });
+
+ const handleLookup = async (values: z.infer) => {
+ const identifier = values.identifier.trim();
+ const isEmail = isValidEmail(identifier);
+ const result = await lookup(identifier);
+
+ if (!result) {
+ // Error already set by hook
+ return;
+ }
+
+ if (!result.found || result.accounts.length === 0) {
+ // No accounts found
+ if (!isEmail || forceLogin) {
+ // Not a valid email or forceLogin is true - show error
+ form.setError("identifier", {
+ type: "manual",
+ message: t("userNotFoundWithUsername")
+ });
+ return;
+ }
+ // Valid email but no accounts and not forceLogin - redirect to signup
+ const signupUrl = redirect
+ ? `/auth/signup?email=${encodeURIComponent(identifier)}&redirect=${encodeURIComponent(redirect)}&fromSmartLogin=true`
+ : `/auth/signup?email=${encodeURIComponent(identifier)}&fromSmartLogin=true`;
+ router.push(signupUrl);
+ return;
+ }
+
+ // Determine which view to show
+ const account = result.accounts[0]; // Use first account for now
+
+ // Check if all accounts are internal-only (no IdPs)
+ const allInternalOnly = result.accounts.every(
+ (acc) =>
+ acc.hasInternalAuth &&
+ acc.orgs.every((org) => org.idps.length === 0)
+ );
+
+ if (allInternalOnly) {
+ // Show password form
+ setViewState({
+ type: "password",
+ identifier,
+ account
+ });
+ return;
+ }
+
+ // Show org selector for both single and multiple orgs
+ setViewState({
+ type: "orgSelector",
+ identifier,
+ lookupResult: result
+ });
+ };
+
+ const handleBack = () => {
+ setViewState({ type: "initial" });
+ form.reset();
+ };
+
+ if (viewState.type === "password") {
+ return (
+
+
+
+
+ );
+ }
+
+ if (viewState.type === "orgSelector") {
+ return (
+
+
+
+ );
+ }
+
+ // Initial view
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx
index 14d12373..b2a5fd8b 100644
--- a/src/components/UserDevicesTable.tsx
+++ b/src/components/UserDevicesTable.tsx
@@ -1,9 +1,8 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
-import { DataTable } from "@app/components/ui/data-table";
-import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
+import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,9 +23,11 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
-import { Badge } from "./ui/badge";
-import { InfoPopup } from "./ui/info-popup";
import ClientDownloadBanner from "./ClientDownloadBanner";
+import { Badge } from "./ui/badge";
+import { build } from "@server/build";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
export type ClientRow = {
id: number;
@@ -44,6 +45,7 @@ export type ClientRow = {
userEmail: string | null;
niceId: string;
agent: string | null;
+ approvalState: "approved" | "pending" | "denied" | null;
archived?: boolean;
blocked?: boolean;
};
@@ -210,11 +212,22 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)}
{r.blocked && (
-
+
{t("blocked")}
)}
+ {r.approvalState === "pending" && (
+
+ {t("pendingApproval")}
+
+ )}
);
}
@@ -272,33 +285,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
);
}
},
- // {
- // accessorKey: "siteName",
- // header: ({ column }) => {
- // return (
- //
- // );
- // },
- // cell: ({ row }) => {
- // const r = row.original;
- // return (
- //
- //
- //
- // );
- // }
- // },
{
accessorKey: "online",
friendlyName: "Connectivity",
@@ -460,7 +446,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}
}}
>
- {clientRow.archived ? "Unarchive" : "Archive"}
+
+ {clientRow.archived
+ ? "Unarchive"
+ : "Archive"}
+
{
@@ -472,7 +462,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}
}}
>
- {clientRow.blocked ? "Unblock" : "Block"}
+
+ {clientRow.blocked
+ ? "Unblock"
+ : "Block"}
+
{!clientRow.userId && (
// Machine client - also show delete option
@@ -482,7 +476,9 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
setIsDeleteModalOpen(true);
}}
>
- Delete
+
+ Delete
+
)}
@@ -570,32 +566,65 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
options: [
{
id: "active",
- label: t("active") || "Active",
+ label: t("active"),
value: "active"
},
+ {
+ id: "pending",
+ label: t("pendingApproval"),
+ value: "pending"
+ },
+ {
+ id: "denied",
+ label: t("deniedApproval"),
+ value: "denied"
+ },
{
id: "archived",
- label: t("archived") || "Archived",
+ label: t("archived"),
value: "archived"
},
{
id: "blocked",
- label: t("blocked") || "Blocked",
+ label: t("blocked"),
value: "blocked"
}
],
- filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
+ filterFn: (
+ row: ClientRow,
+ selectedValues: (string | number | boolean)[]
+ ) => {
if (selectedValues.length === 0) return true;
- const rowArchived = row.archived || false;
- const rowBlocked = row.blocked || false;
+ const rowArchived = row.archived;
+ const rowBlocked = row.blocked;
+ const approvalState = row.approvalState;
const isActive = !rowArchived && !rowBlocked;
-
- if (selectedValues.includes("active") && isActive) return true;
- if (selectedValues.includes("archived") && rowArchived) return true;
- if (selectedValues.includes("blocked") && rowBlocked) return true;
+
+ if (selectedValues.includes("active") && isActive)
+ return true;
+ if (
+ selectedValues.includes("pending") &&
+ approvalState === "pending"
+ )
+ return true;
+ if (
+ selectedValues.includes("denied") &&
+ approvalState === "denied"
+ )
+ return true;
+ if (
+ selectedValues.includes("archived") &&
+ rowArchived
+ )
+ return true;
+ if (
+ selectedValues.includes("blocked") &&
+ rowBlocked
+ )
+ return true;
return false;
},
- defaultValues: ["active"] // Default to showing active clients
+ defaultValues: ["active", "pending"] // Default to showing active clients
}
]}
/>
diff --git a/src/components/UserProfileCard.tsx b/src/components/UserProfileCard.tsx
new file mode 100644
index 00000000..869ae690
--- /dev/null
+++ b/src/components/UserProfileCard.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
+
+type UserProfileCardProps = {
+ identifier: string;
+ description?: string;
+ onUseDifferentAccount?: () => void;
+ useDifferentAccountText?: string;
+};
+
+export default function UserProfileCard({
+ identifier,
+ description,
+ onUseDifferentAccount,
+ useDifferentAccountText
+}: UserProfileCardProps) {
+ // Create profile label and initial from identifier
+ const profileLabel = identifier.trim();
+ const profileInitial = profileLabel
+ ? profileLabel.charAt(0).toUpperCase()
+ : "";
+
+ return (
+
+
+ {profileInitial}
+
+
+
+
{profileLabel}
+ {description && (
+
+ {description}
+
+ )}
+
+ {onUseDifferentAccount && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/VerifyEmailForm.tsx b/src/components/VerifyEmailForm.tsx
index 14a362df..31a60819 100644
--- a/src/components/VerifyEmailForm.tsx
+++ b/src/components/VerifyEmailForm.tsx
@@ -245,7 +245,7 @@ export default function VerifyEmailForm({
className="w-full"
onClick={logout}
>
- Log in with another account
+ {t("verifyEmailLogInWithDifferentAccount")}
diff --git a/src/components/private/SplashImage.tsx b/src/components/private/SplashImage.tsx
index ad9d5069..576577c4 100644
--- a/src/components/private/SplashImage.tsx
+++ b/src/components/private/SplashImage.tsx
@@ -21,7 +21,7 @@ export default function SplashImage({ children }: SplashImageProps) {
if (!env.branding.background_image_path) {
return false;
}
- const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource"];
+ const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource", "/auth/org"];
for (const prefix of pathsPrefixes) {
if (pathname.startsWith(prefix)) {
return true;
diff --git a/src/components/private/ValidateSessionTransferToken.tsx b/src/components/private/ValidateSessionTransferToken.tsx
index c83b61ba..cdd3cb34 100644
--- a/src/components/private/ValidateSessionTransferToken.tsx
+++ b/src/components/private/ValidateSessionTransferToken.tsx
@@ -50,9 +50,13 @@ export default function ValidateSessionTransferToken(
}
if (doRedirect) {
- // add redirect param to dashboardUrl if provided
- const fullUrl = `${env.app.dashboardUrl}${props.redirect || ""}`;
- router.push(fullUrl);
+ if (props.redirect && props.redirect.startsWith("http")) {
+ router.push(props.redirect);
+ } else {
+ // add redirect param to dashboardUrl if provided
+ const fullUrl = `${env.app.dashboardUrl}${props.redirect || ""}`;
+ router.push(fullUrl);
+ }
}
}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
index 85825dc1..261655bb 100644
--- a/src/components/ui/checkbox.tsx
+++ b/src/components/ui/checkbox.tsx
@@ -30,7 +30,8 @@ const checkboxVariants = cva(
);
interface CheckboxProps
- extends React.ComponentPropsWithoutRef,
+ extends
+ React.ComponentPropsWithoutRef,
VariantProps {}
const Checkbox = React.forwardRef<
@@ -49,17 +50,18 @@ const Checkbox = React.forwardRef<
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
-interface CheckboxWithLabelProps
- extends React.ComponentPropsWithoutRef {
+interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
+ typeof Checkbox
+> {
label: string;
}
const CheckboxWithLabel = React.forwardRef<
- React.ElementRef,
+ React.ComponentRef,
CheckboxWithLabelProps
>(({ className, label, id, ...props }, ref) => {
return (
-
+