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({
+ startTransition(() => onSubmit(values)) + )} className="space-y-4" id="create-role-form" > @@ -159,6 +160,56 @@ export default function CreateRoleForm({ )} /> + {build !== "oss" && ( +
+ + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + + + + {t( + "requireDeviceApprovalDescription" + )} + + + + + )} + /> +
+ )}
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({

{getSubtitle()}

- {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" - )} -

-
- -
-
+ +
+ + + + + + + + ); +} 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 ( + +
+ +
+
+

{subtitle}

+
+
+ ); +} 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 && ( <>
@@ -488,115 +333,36 @@ export default function LoginForm({ )} {mfaRequested && ( - <> -
-

{t("otpAuth")}

-

- {t("otpAuthDescription")} -

-
- - - ( - - -
- { - field.onChange(value); - if ( - value.length === 6 - ) { - mfaForm.handleSubmit( - onSubmit - )(); - } - }} - > - - - - - - - - - -
-
- -
- )} - /> - - - + { + 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 ( +
+
+ + ( + + {t("password")} + + + + + + )} + /> + + {error && ( + + {error} + + )} + +
+ + {t("passwordForgot")} + +
+ + + + +
+ ); +} 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")} +

+
+
+ + ( + + +
+ { + field.onChange(value); + if (value.length === 6) { + form.handleSubmit(onSubmit)(); + } + }} + > + + + + + + + + + +
+
+ +
+ )} + /> + + + + {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 ( +
+
+ + ( + + {t("usernameOrEmail")} + + + + + + )} + /> + + {(error || securityKeyError) && ( + + + {error || securityKeyError} + + + )} + + + +
+ + + +
+
+ ); +} 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 ( -
+