mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-07 21:46:38 +00:00
Merge branch 'dev' into msg-delivery
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,4 +50,5 @@ dynamic/
|
|||||||
*.mmdb
|
*.mmdb
|
||||||
scratch/
|
scratch/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
hydrateSaas.ts
|
hydrateSaas.ts
|
||||||
|
CLAUDE.md
|
||||||
@@ -257,6 +257,8 @@
|
|||||||
"accessRolesSearch": "Search roles...",
|
"accessRolesSearch": "Search roles...",
|
||||||
"accessRolesAdd": "Add Role",
|
"accessRolesAdd": "Add Role",
|
||||||
"accessRoleDelete": "Delete Role",
|
"accessRoleDelete": "Delete Role",
|
||||||
|
"accessApprovalsManage": "Manage Approvals",
|
||||||
|
"accessApprovalsDescription": "Manage approval requests in the organization",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"inviteTitle": "Open Invitations",
|
"inviteTitle": "Open Invitations",
|
||||||
"inviteDescription": "Manage invitations for other users to join the organization",
|
"inviteDescription": "Manage invitations for other users to join the organization",
|
||||||
@@ -450,6 +452,18 @@
|
|||||||
"selectDuration": "Select duration",
|
"selectDuration": "Select duration",
|
||||||
"selectResource": "Select Resource",
|
"selectResource": "Select Resource",
|
||||||
"filterByResource": "Filter By 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",
|
"resetFilters": "Reset Filters",
|
||||||
"totalBlocked": "Requests Blocked By Pangolin",
|
"totalBlocked": "Requests Blocked By Pangolin",
|
||||||
"totalRequests": "Total Requests",
|
"totalRequests": "Total Requests",
|
||||||
@@ -729,16 +743,28 @@
|
|||||||
"countries": "Countries",
|
"countries": "Countries",
|
||||||
"accessRoleCreate": "Create Role",
|
"accessRoleCreate": "Create Role",
|
||||||
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
||||||
|
"accessRoleEdit": "Edit Role",
|
||||||
|
"accessRoleEditDescription": "Edit role information.",
|
||||||
"accessRoleCreateSubmit": "Create Role",
|
"accessRoleCreateSubmit": "Create Role",
|
||||||
"accessRoleCreated": "Role created",
|
"accessRoleCreated": "Role created",
|
||||||
"accessRoleCreatedDescription": "The role has been successfully created.",
|
"accessRoleCreatedDescription": "The role has been successfully created.",
|
||||||
"accessRoleErrorCreate": "Failed to create role",
|
"accessRoleErrorCreate": "Failed to create role",
|
||||||
"accessRoleErrorCreateDescription": "An error occurred while creating the 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",
|
"accessRoleErrorNewRequired": "New role is required",
|
||||||
"accessRoleErrorRemove": "Failed to remove role",
|
"accessRoleErrorRemove": "Failed to remove role",
|
||||||
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
||||||
"accessRoleName": "Role Name",
|
"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",
|
"accessRoleRemove": "Remove Role",
|
||||||
"accessRoleRemoveDescription": "Remove a role from the organization",
|
"accessRoleRemoveDescription": "Remove a role from the organization",
|
||||||
"accessRoleRemoveSubmit": "Remove Role",
|
"accessRoleRemoveSubmit": "Remove Role",
|
||||||
@@ -874,7 +900,7 @@
|
|||||||
"inviteAlready": "Looks like you've been invited!",
|
"inviteAlready": "Looks like you've been invited!",
|
||||||
"inviteAlreadyDescription": "To accept the invite, you must log in or create an account.",
|
"inviteAlreadyDescription": "To accept the invite, you must log in or create an account.",
|
||||||
"signupQuestion": "Already have an account?",
|
"signupQuestion": "Already have an account?",
|
||||||
"login": "Log in",
|
"login": "Log In",
|
||||||
"resourceNotFound": "Resource Not Found",
|
"resourceNotFound": "Resource Not Found",
|
||||||
"resourceNotFoundDescription": "The resource you're trying to access does not exist.",
|
"resourceNotFoundDescription": "The resource you're trying to access does not exist.",
|
||||||
"pincodeRequirementsLength": "PIN must be exactly 6 digits",
|
"pincodeRequirementsLength": "PIN must be exactly 6 digits",
|
||||||
@@ -954,13 +980,13 @@
|
|||||||
"passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.",
|
"passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.",
|
||||||
"changePasswordNow": "Change Password Now",
|
"changePasswordNow": "Change Password Now",
|
||||||
"pincodeAuth": "Authenticator Code",
|
"pincodeAuth": "Authenticator Code",
|
||||||
"pincodeSubmit2": "Submit Code",
|
"pincodeSubmit2": "Submit code",
|
||||||
"passwordResetSubmit": "Request Reset",
|
"passwordResetSubmit": "Request Reset",
|
||||||
"passwordResetAlreadyHaveCode": "Enter Code",
|
"passwordResetAlreadyHaveCode": "Enter Code",
|
||||||
"passwordResetSmtpRequired": "Please contact your administrator",
|
"passwordResetSmtpRequired": "Please contact your administrator",
|
||||||
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
|
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
|
||||||
"passwordBack": "Back to Password",
|
"passwordBack": "Back to Password",
|
||||||
"loginBack": "Go back to log in",
|
"loginBack": "Go back to main login page",
|
||||||
"signup": "Sign up",
|
"signup": "Sign up",
|
||||||
"loginStart": "Log in to get started",
|
"loginStart": "Log in to get started",
|
||||||
"idpOidcTokenValidating": "Validating OIDC token",
|
"idpOidcTokenValidating": "Validating OIDC token",
|
||||||
@@ -1138,14 +1164,14 @@
|
|||||||
"searchProgress": "Search...",
|
"searchProgress": "Search...",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"orgs": "Organizations",
|
"orgs": "Organizations",
|
||||||
"loginError": "An error occurred while logging in",
|
"loginError": "An unexpected error occurred. Please try again.",
|
||||||
"loginRequiredForDevice": "Login is required for your device.",
|
"loginRequiredForDevice": "Login is required for your device.",
|
||||||
"passwordForgot": "Forgot your password?",
|
"passwordForgot": "Forgot your password?",
|
||||||
"otpAuth": "Two-Factor Authentication",
|
"otpAuth": "Two-Factor Authentication",
|
||||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||||
"otpAuthSubmit": "Submit Code",
|
"otpAuthSubmit": "Submit Code",
|
||||||
"idpContinue": "Or continue with",
|
"idpContinue": "Or continue with",
|
||||||
"otpAuthBack": "Back to Log In",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
"navbarDocsLink": "Documentation",
|
"navbarDocsLink": "Documentation",
|
||||||
@@ -1193,6 +1219,7 @@
|
|||||||
"sidebarOverview": "Overview",
|
"sidebarOverview": "Overview",
|
||||||
"sidebarHome": "Home",
|
"sidebarHome": "Home",
|
||||||
"sidebarSites": "Sites",
|
"sidebarSites": "Sites",
|
||||||
|
"sidebarApprovals": "Approval Requests",
|
||||||
"sidebarResources": "Resources",
|
"sidebarResources": "Resources",
|
||||||
"sidebarProxyResources": "Public",
|
"sidebarProxyResources": "Public",
|
||||||
"sidebarClientResources": "Private",
|
"sidebarClientResources": "Private",
|
||||||
@@ -1209,7 +1236,7 @@
|
|||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients",
|
||||||
"sidebarUserDevices": "Users",
|
"sidebarUserDevices": "User Devices",
|
||||||
"sidebarMachineClients": "Machines",
|
"sidebarMachineClients": "Machines",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"sidebarGeneral": "Manage",
|
"sidebarGeneral": "Manage",
|
||||||
@@ -1308,6 +1335,7 @@
|
|||||||
"refreshError": "Failed to refresh data",
|
"refreshError": "Failed to refresh data",
|
||||||
"verified": "Verified",
|
"verified": "Verified",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
|
"pendingApproval": "Pending Approval",
|
||||||
"sidebarBilling": "Billing",
|
"sidebarBilling": "Billing",
|
||||||
"billing": "Billing",
|
"billing": "Billing",
|
||||||
"orgBillingDescription": "Manage billing information and subscriptions",
|
"orgBillingDescription": "Manage billing information and subscriptions",
|
||||||
@@ -1424,7 +1452,7 @@
|
|||||||
"securityKeyRemoveSuccess": "Security key removed successfully",
|
"securityKeyRemoveSuccess": "Security key removed successfully",
|
||||||
"securityKeyRemoveError": "Failed to remove security key",
|
"securityKeyRemoveError": "Failed to remove security key",
|
||||||
"securityKeyLoadError": "Failed to load security keys",
|
"securityKeyLoadError": "Failed to load security keys",
|
||||||
"securityKeyLogin": "Continue with security key",
|
"securityKeyLogin": "Use Security Key",
|
||||||
"securityKeyAuthError": "Failed to authenticate with 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.",
|
"securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.",
|
||||||
"registering": "Registering...",
|
"registering": "Registering...",
|
||||||
@@ -1551,6 +1579,8 @@
|
|||||||
"IntervalSeconds": "Healthy Interval",
|
"IntervalSeconds": "Healthy Interval",
|
||||||
"timeoutSeconds": "Timeout (sec)",
|
"timeoutSeconds": "Timeout (sec)",
|
||||||
"timeIsInSeconds": "Time is in seconds",
|
"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",
|
"retryAttempts": "Retry Attempts",
|
||||||
"expectedResponseCodes": "Expected Response Codes",
|
"expectedResponseCodes": "Expected Response Codes",
|
||||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
"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",
|
"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.",
|
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
|
||||||
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
|
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
|
||||||
"orgAuthSignInToOrg": "Use organization's identity provider",
|
"orgAuthSignInToOrg": "Sign in to an organization",
|
||||||
"orgAuthSelectOrgTitle": "Organization Sign In",
|
"orgAuthSelectOrgTitle": "Organization Sign In",
|
||||||
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
|
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
|
||||||
"orgAuthOrgIdPlaceholder": "your-organization",
|
"orgAuthOrgIdPlaceholder": "your-organization",
|
||||||
@@ -2236,6 +2266,8 @@
|
|||||||
"deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)",
|
"deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)",
|
||||||
"deviceCodeInvalidOrExpired": "Invalid or expired code",
|
"deviceCodeInvalidOrExpired": "Invalid or expired code",
|
||||||
"deviceCodeVerifyFailed": "Failed to verify device code",
|
"deviceCodeVerifyFailed": "Failed to verify device code",
|
||||||
|
"deviceCodeValidating": "Validating device code...",
|
||||||
|
"deviceCodeVerifying": "Verifying device authorization...",
|
||||||
"signedInAs": "Signed in as",
|
"signedInAs": "Signed in as",
|
||||||
"deviceCodeEnterPrompt": "Enter the code displayed on the device",
|
"deviceCodeEnterPrompt": "Enter the code displayed on the device",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
@@ -2310,6 +2342,7 @@
|
|||||||
"identifier": "Identifier",
|
"identifier": "Identifier",
|
||||||
"deviceLoginUseDifferentAccount": "Not you? Use a different account.",
|
"deviceLoginUseDifferentAccount": "Not you? Use a different account.",
|
||||||
"deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.",
|
"deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.",
|
||||||
|
"loginSelectAuthenticationMethod": "Select an authentication method to continue.",
|
||||||
"noData": "No Data",
|
"noData": "No Data",
|
||||||
"machineClients": "Machine Clients",
|
"machineClients": "Machine Clients",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
@@ -2424,5 +2457,30 @@
|
|||||||
"blockClientQuestion": "Are you sure you want to block this client?",
|
"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.",
|
"blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.",
|
||||||
"blockClientConfirm": "Block Client",
|
"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 <termsOfService>Terms of Service</termsOfService> and <privacyPolicy>Privacy Policy</privacyPolicy>.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ export enum ActionsEnum {
|
|||||||
getBlueprint = "getBlueprint",
|
getBlueprint = "getBlueprint",
|
||||||
applyBlueprint = "applyBlueprint",
|
applyBlueprint = "applyBlueprint",
|
||||||
viewLogs = "viewLogs",
|
viewLogs = "viewLogs",
|
||||||
exportLogs = "exportLogs"
|
exportLogs = "exportLogs",
|
||||||
|
listApprovals = "listApprovals",
|
||||||
|
updateApprovals = "updateApprovals"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
150
server/db/ios_models.json
Normal file
150
server/db/ios_models.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
201
server/db/mac_models.json
Normal file
201
server/db/mac_models.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -16,6 +16,24 @@ if (!dev) {
|
|||||||
}
|
}
|
||||||
export const names = JSON.parse(readFileSync(file, "utf-8"));
|
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<string, string> = JSON.parse(
|
||||||
|
readFileSync(iosModelsFile, "utf-8")
|
||||||
|
);
|
||||||
|
const macModels: Record<string, string> = JSON.parse(
|
||||||
|
readFileSync(macModelsFile, "utf-8")
|
||||||
|
);
|
||||||
|
|
||||||
export async function getUniqueClientName(orgId: string): Promise<string> {
|
export async function getUniqueClientName(orgId: string): Promise<string> {
|
||||||
let loops = 0;
|
let loops = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -159,3 +177,29 @@ export function generateName(): string {
|
|||||||
// clean out any non-alphanumeric characters except for dashes
|
// clean out any non-alphanumeric characters except for dashes
|
||||||
return name.replace(/[^a-z0-9-]/g, "");
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import {
|
|||||||
index
|
index
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
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", {
|
export const certificates = pgTable("certificates", {
|
||||||
certId: serial("certId").primaryKey(),
|
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<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
export type Certificate = InferSelectModel<typeof certificates>;
|
export type Certificate = InferSelectModel<typeof certificates>;
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ export const roles = pgTable("roles", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
isAdmin: boolean("isAdmin"),
|
isAdmin: boolean("isAdmin"),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
description: varchar("description")
|
description: varchar("description"),
|
||||||
|
requireDeviceApproval: boolean("requireDeviceApproval").default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleActions = pgTable("roleActions", {
|
export const roleActions = pgTable("roleActions", {
|
||||||
@@ -591,7 +592,8 @@ export const idp = pgTable("idp", {
|
|||||||
type: varchar("type").notNull(),
|
type: varchar("type").notNull(),
|
||||||
defaultRoleMapping: varchar("defaultRoleMapping"),
|
defaultRoleMapping: varchar("defaultRoleMapping"),
|
||||||
defaultOrgMapping: varchar("defaultOrgMapping"),
|
defaultOrgMapping: varchar("defaultOrgMapping"),
|
||||||
autoProvision: boolean("autoProvision").notNull().default(false)
|
autoProvision: boolean("autoProvision").notNull().default(false),
|
||||||
|
tags: text("tags")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const idpOidcConfig = pgTable("idpOidcConfig", {
|
export const idpOidcConfig = pgTable("idpOidcConfig", {
|
||||||
@@ -690,7 +692,10 @@ export const clients = pgTable("clients", {
|
|||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
maxConnections: integer("maxConnections"),
|
maxConnections: integer("maxConnections"),
|
||||||
archived: boolean("archived").notNull().default(false),
|
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(
|
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", {
|
export const olms = pgTable("olms", {
|
||||||
olmId: varchar("id").primaryKey(),
|
olmId: varchar("id").primaryKey(),
|
||||||
secretHash: varchar("secretHash").notNull(),
|
secretHash: varchar("secretHash").notNull(),
|
||||||
@@ -732,6 +780,27 @@ export const olms = pgTable("olms", {
|
|||||||
archived: boolean("archived").notNull().default(false)
|
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", {
|
export const olmSessions = pgTable("clientSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
olmId: varchar("olmId")
|
olmId: varchar("olmId")
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Resource,
|
Resource,
|
||||||
ResourcePassword,
|
ResourcePassword,
|
||||||
@@ -108,9 +108,17 @@ export async function getUserSessionWithUser(
|
|||||||
*/
|
*/
|
||||||
export async function getUserOrgRole(userId: string, orgId: string) {
|
export async function getUserOrgRole(userId: string, orgId: string) {
|
||||||
const userOrgRole = await db
|
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)
|
.from(userOrgs)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||||
|
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return userOrgRole.length > 0 ? userOrgRole[0] : null;
|
return userOrgRole.length > 0 ? userOrgRole[0] : null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
sqliteTable,
|
sqliteTable,
|
||||||
text
|
text
|
||||||
} from "drizzle-orm/sqlite-core";
|
} 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", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
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<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
export type Certificate = InferSelectModel<typeof certificates>;
|
export type Certificate = InferSelectModel<typeof certificates>;
|
||||||
|
|||||||
@@ -255,7 +255,9 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
aliasAddress: text("aliasAddress"),
|
aliasAddress: text("aliasAddress"),
|
||||||
tcpPortRangeString: text("tcpPortRangeString").notNull().default("*"),
|
tcpPortRangeString: text("tcpPortRangeString").notNull().default("*"),
|
||||||
udpPortRangeString: text("udpPortRangeString").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", {
|
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||||
@@ -385,7 +387,10 @@ export const clients = sqliteTable("clients", {
|
|||||||
// endpoint: text("endpoint"),
|
// endpoint: text("endpoint"),
|
||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
|
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(
|
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", {
|
export const olms = sqliteTable("olms", {
|
||||||
olmId: text("id").primaryKey(),
|
olmId: text("id").primaryKey(),
|
||||||
secretHash: text("secretHash").notNull(),
|
secretHash: text("secretHash").notNull(),
|
||||||
@@ -429,6 +497,27 @@ export const olms = sqliteTable("olms", {
|
|||||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
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", {
|
export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
|
||||||
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
@@ -518,7 +607,10 @@ export const roles = sqliteTable("roles", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description")
|
description: text("description"),
|
||||||
|
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||||
|
mode: "boolean"
|
||||||
|
}).default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleActions = sqliteTable("roleActions", {
|
export const roleActions = sqliteTable("roleActions", {
|
||||||
@@ -777,7 +869,8 @@ export const idp = sqliteTable("idp", {
|
|||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false)
|
.default(false),
|
||||||
|
tags: text("tags")
|
||||||
});
|
});
|
||||||
|
|
||||||
// Identity Provider OAuth Configuration
|
// Identity Provider OAuth Configuration
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
|
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||||
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
|
approvals,
|
||||||
clients,
|
clients,
|
||||||
db,
|
db,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
roles,
|
roles,
|
||||||
|
Transaction,
|
||||||
userClients,
|
userClients,
|
||||||
userOrgs,
|
userOrgs
|
||||||
Transaction
|
|
||||||
} from "@server/db";
|
} 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 { 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(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -38,13 +41,15 @@ export async function calculateUserClientsForOrgs(
|
|||||||
const allUserOrgs = await transaction
|
const allUserOrgs = await transaction
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
|
.innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
|
||||||
.where(eq(userOrgs.userId, userId));
|
.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 each OLM, ensure there's a client in each org the user is in
|
||||||
for (const olm of userOlms) {
|
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 orgId = userOrg.orgId;
|
||||||
|
|
||||||
const [org] = await transaction
|
const [org] = await transaction
|
||||||
@@ -182,21 +187,46 @@ export async function calculateUserClientsForOrgs(
|
|||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
|
userOrg.orgId
|
||||||
|
);
|
||||||
|
const requireApproval =
|
||||||
|
build !== "oss" &&
|
||||||
|
isOrgLicensed &&
|
||||||
|
role.requireDeviceApproval;
|
||||||
|
|
||||||
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
|
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
|
// Create the client
|
||||||
const [newClient] = await transaction
|
const [newClient] = await transaction
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values({
|
.values(newClientData)
|
||||||
userId,
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
|
||||||
subnet: updatedSubnet,
|
|
||||||
olmId: olm.olmId,
|
|
||||||
type: "olm",
|
|
||||||
niceId
|
|
||||||
})
|
|
||||||
.returning();
|
.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(
|
await rebuildClientAssociationsFromClient(
|
||||||
newClient,
|
newClient,
|
||||||
transaction
|
transaction
|
||||||
|
|||||||
15
server/private/routers/approvals/index.ts
Normal file
15
server/private/routers/approvals/index.ts
Normal file
@@ -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";
|
||||||
188
server/private/routers/approvals/listApprovals.ts
Normal file
188
server/private/routers/approvals/listApprovals.ts
Normal file
@@ -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<typeof querySchema>["approvalState"]
|
||||||
|
) {
|
||||||
|
let state: Array<Approval["decision"]> = [];
|
||||||
|
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<Awaited<ReturnType<typeof queryApprovals>>>;
|
||||||
|
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<number>`count(*)` })
|
||||||
|
.from(approvals);
|
||||||
|
|
||||||
|
return response<ListApprovalsResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
server/private/routers/approvals/processPendingApproval.ts
Normal file
142
server/private/routers/approvals/processPendingApproval.ts
Normal file
@@ -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<InferInsertModel<typeof clients>> = {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import * as generateLicense from "./generatedLicense";
|
|||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
import * as misc from "#private/routers/misc";
|
import * as misc from "#private/routers/misc";
|
||||||
import * as reKey from "#private/routers/re-key";
|
import * as reKey from "#private/routers/re-key";
|
||||||
|
import * as approval from "#private/routers/approvals";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -311,6 +312,24 @@ authenticated.get(
|
|||||||
loginPage.getLoginPage
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -29,11 +29,9 @@ import { getOrgTierData } from "#private/lib/billing";
|
|||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z.strictObject({
|
||||||
.object({
|
orgId: z.string()
|
||||||
orgId: z.string()
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export async function getLoginPageBranding(
|
export async function getLoginPageBranding(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const bodySchema = z.strictObject({
|
|||||||
scopes: z.string().nonempty(),
|
scopes: z.string().nonempty(),
|
||||||
autoProvision: z.boolean().optional(),
|
autoProvision: z.boolean().optional(),
|
||||||
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
|
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
|
||||||
roleMapping: z.string().optional()
|
roleMapping: z.string().optional(),
|
||||||
|
tags: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -104,7 +105,8 @@ export async function createOrgOidcIdp(
|
|||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
variant,
|
variant,
|
||||||
roleMapping
|
roleMapping,
|
||||||
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
@@ -132,7 +134,8 @@ export async function createOrgOidcIdp(
|
|||||||
.values({
|
.values({
|
||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
type: "oidc"
|
type: "oidc",
|
||||||
|
tags
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ async function query(orgId: string, limit: number, offset: number) {
|
|||||||
orgId: idpOrg.orgId,
|
orgId: idpOrg.orgId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
type: idp.type,
|
type: idp.type,
|
||||||
variant: idpOidcConfig.variant
|
variant: idpOidcConfig.variant,
|
||||||
|
tags: idp.tags
|
||||||
})
|
})
|
||||||
.from(idpOrg)
|
.from(idpOrg)
|
||||||
.where(eq(idpOrg.orgId, orgId))
|
.where(eq(idpOrg.orgId, orgId))
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ const bodySchema = z.strictObject({
|
|||||||
namePath: z.string().optional(),
|
namePath: z.string().optional(),
|
||||||
scopes: z.string().optional(),
|
scopes: z.string().optional(),
|
||||||
autoProvision: z.boolean().optional(),
|
autoProvision: z.boolean().optional(),
|
||||||
roleMapping: z.string().optional()
|
roleMapping: z.string().optional(),
|
||||||
|
tags: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateOrgIdpResponse = {
|
export type UpdateOrgIdpResponse = {
|
||||||
@@ -109,7 +110,8 @@ export async function updateOrgOidcIdp(
|
|||||||
namePath,
|
namePath,
|
||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
roleMapping
|
roleMapping,
|
||||||
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
@@ -167,7 +169,8 @@ export async function updateOrgOidcIdp(
|
|||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const idpData = {
|
const idpData = {
|
||||||
name,
|
name,
|
||||||
autoProvision
|
autoProvision,
|
||||||
|
tags
|
||||||
};
|
};
|
||||||
|
|
||||||
// only update if at least one key is not undefined
|
// only update if at least one key is not undefined
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ export * from "./checkResourceSession";
|
|||||||
export * from "./securityKey";
|
export * from "./securityKey";
|
||||||
export * from "./startDeviceWebAuth";
|
export * from "./startDeviceWebAuth";
|
||||||
export * from "./verifyDeviceWebAuth";
|
export * from "./verifyDeviceWebAuth";
|
||||||
export * from "./pollDeviceWebAuth";
|
export * from "./pollDeviceWebAuth";
|
||||||
|
export * from "./lookupUser";
|
||||||
224
server/routers/auth/lookupUser.ts
Normal file
224
server/routers/auth/lookupUser.ts
Normal file
@@ -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<any> {
|
||||||
|
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<LookupUserResponse>(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<string, typeof userOrgMemberships[0]>();
|
||||||
|
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<LookupUserResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm";
|
|||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import { getIosDeviceName, getMacDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
const bodySchema = z
|
const bodySchema = z
|
||||||
.object({
|
.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 is false, just return metadata without verifying
|
||||||
if (!verify) {
|
if (!verify) {
|
||||||
return response<VerifyDeviceWebAuthResponse>(res, {
|
return response<VerifyDeviceWebAuthResponse>(res, {
|
||||||
@@ -129,7 +135,7 @@ export async function verifyDeviceWebAuth(
|
|||||||
metadata: {
|
metadata: {
|
||||||
ip: deviceCode.ip,
|
ip: deviceCode.ip,
|
||||||
city: deviceCode.city,
|
city: deviceCode.city,
|
||||||
deviceName: deviceCode.deviceName,
|
deviceName: deviceName,
|
||||||
applicationName: deviceCode.applicationName,
|
applicationName: deviceCode.applicationName,
|
||||||
createdAt: deviceCode.createdAt
|
createdAt: deviceCode.createdAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -942,7 +942,7 @@ async function isUserAllowedToAccessResource(
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role
|
role: userOrgRole.roleName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +956,7 @@ async function isUserAllowedToAccessResource(
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role
|
role: userOrgRole.roleName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export async function blockClient(
|
|||||||
// Block the client
|
// Block the client
|
||||||
await trx
|
await trx
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ blocked: true })
|
.set({ blocked: true, approvalState: "denied" })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
// Send terminate signal if there's an associated OLM and it's connected
|
// Send terminate signal if there's an associated OLM and it's connected
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms } from "@server/db";
|
||||||
import { clients } from "@server/db";
|
import { clients, fingerprints } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
|||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
const getClientSchema = z.strictObject({
|
const getClientSchema = z.strictObject({
|
||||||
clientId: z
|
clientId: z
|
||||||
@@ -29,6 +30,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
|||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.clientId, clientId))
|
.where(eq(clients.clientId, clientId))
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
|
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return res;
|
return res;
|
||||||
} else if (niceId && orgId) {
|
} else if (niceId && orgId) {
|
||||||
@@ -37,6 +39,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
|||||||
.from(clients)
|
.from(clients)
|
||||||
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
|
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
|
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return res;
|
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 = {
|
const data: GetClientResponse = {
|
||||||
...client.clients,
|
...client.clients,
|
||||||
|
name: clientName,
|
||||||
olmId: client.olms ? client.olms.olmId : null
|
olmId: client.olms ? client.olms.olmId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
clientSitesAssociationsCache
|
clientSitesAssociationsCache,
|
||||||
|
fingerprints
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -27,6 +28,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
@@ -137,14 +139,17 @@ function queryClients(
|
|||||||
userEmail: users.email,
|
userEmail: users.email,
|
||||||
niceId: clients.niceId,
|
niceId: clients.niceId,
|
||||||
agent: olms.agent,
|
agent: olms.agent,
|
||||||
|
approvalState: clients.approvalState,
|
||||||
olmArchived: olms.archived,
|
olmArchived: olms.archived,
|
||||||
archived: clients.archived,
|
archived: clients.archived,
|
||||||
blocked: clients.blocked
|
blocked: clients.blocked,
|
||||||
|
deviceModel: fingerprints.deviceModel
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(users, eq(clients.userId, users.userId))
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
|
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,21 +168,22 @@ async function getSiteAssociations(clientIds: number[]) {
|
|||||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
type OlmWithUpdateAvailable = Awaited<ReturnType<typeof queryClients>>[0] & {
|
type ClientWithSites = Omit<
|
||||||
|
Awaited<ReturnType<typeof queryClients>>[0],
|
||||||
|
"deviceModel"
|
||||||
|
> & {
|
||||||
|
sites: Array<{
|
||||||
|
siteId: number;
|
||||||
|
siteName: string | null;
|
||||||
|
siteNiceId: string | null;
|
||||||
|
}>;
|
||||||
olmUpdateAvailable?: boolean;
|
olmUpdateAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
|
|
||||||
export type ListClientsResponse = {
|
export type ListClientsResponse = {
|
||||||
clients: Array<
|
clients: Array<ClientWithSites>;
|
||||||
Awaited<ReturnType<typeof queryClients>>[0] & {
|
|
||||||
sites: Array<{
|
|
||||||
siteId: number;
|
|
||||||
siteName: string | null;
|
|
||||||
siteNiceId: string | null;
|
|
||||||
}>;
|
|
||||||
olmUpdateAvailable?: boolean;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
pagination: { total: number; limit: number; offset: number };
|
pagination: { total: number; limit: number; offset: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,11 +313,17 @@ export async function listClients(
|
|||||||
>
|
>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge clients with their site associations
|
// Merge clients with their site associations and replace name with device name
|
||||||
const clientsWithSites = clientsList.map((client) => ({
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
...client,
|
const model = client.deviceModel || null;
|
||||||
sites: sitesByClient[client.clientId] || []
|
const newName = getUserDeviceName(model, client.name);
|
||||||
}));
|
const { deviceModel, ...clientWithoutDeviceModel } = client;
|
||||||
|
return {
|
||||||
|
...clientWithoutDeviceModel,
|
||||||
|
name: newName,
|
||||||
|
sites: sitesByClient[client.clientId] || []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const latestOlVersionPromise = getLatestOlmVersion();
|
const latestOlVersionPromise = getLatestOlmVersion();
|
||||||
|
|
||||||
@@ -350,7 +362,7 @@ export async function listClients(
|
|||||||
|
|
||||||
return response<ListClientsResponse>(res, {
|
return response<ListClientsResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
clients: clientsWithSites,
|
clients: olmsWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function unblockClient(
|
|||||||
// Unblock the client
|
// Unblock the client
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ blocked: false })
|
.set({ blocked: false, approvalState: null })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -586,6 +586,14 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.listRoles),
|
verifyUserHasAction(ActionsEnum.listRoles),
|
||||||
role.listRoles
|
role.listRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/role/:roleId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateRole),
|
||||||
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
|
role.updateRole
|
||||||
|
);
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
// "/role/:roleId",
|
// "/role/:roleId",
|
||||||
// verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
@@ -861,6 +869,12 @@ authenticated.get(
|
|||||||
olm.getUserOlm
|
olm.getUserOlm
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/user/:userId/olm/recover",
|
||||||
|
verifyIsLoggedInUser,
|
||||||
|
olm.recoverOlmWithFingerprint
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/idp/oidc",
|
"/idp/oidc",
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
@@ -1107,6 +1121,21 @@ authRouter.post(
|
|||||||
auth.login
|
auth.login
|
||||||
);
|
);
|
||||||
authRouter.post("/logout", auth.logout);
|
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(
|
authRouter.post(
|
||||||
"/newt/get-token",
|
"/newt/get-token",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const bodySchema = z.strictObject({
|
|||||||
emailPath: z.string().optional(),
|
emailPath: z.string().optional(),
|
||||||
namePath: z.string().optional(),
|
namePath: z.string().optional(),
|
||||||
scopes: z.string().nonempty(),
|
scopes: z.string().nonempty(),
|
||||||
autoProvision: z.boolean().optional()
|
autoProvision: z.boolean().optional(),
|
||||||
|
tags: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateIdpResponse = {
|
export type CreateIdpResponse = {
|
||||||
@@ -75,7 +76,8 @@ export async function createOidcIdp(
|
|||||||
emailPath,
|
emailPath,
|
||||||
namePath,
|
namePath,
|
||||||
name,
|
name,
|
||||||
autoProvision
|
autoProvision,
|
||||||
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
@@ -90,7 +92,8 @@ export async function createOidcIdp(
|
|||||||
.values({
|
.values({
|
||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
type: "oidc"
|
type: "oidc",
|
||||||
|
tags
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ async function query(limit: number, offset: number) {
|
|||||||
type: idp.type,
|
type: idp.type,
|
||||||
variant: idpOidcConfig.variant,
|
variant: idpOidcConfig.variant,
|
||||||
orgCount: sql<number>`count(${idpOrg.orgId})`,
|
orgCount: sql<number>`count(${idpOrg.orgId})`,
|
||||||
autoProvision: idp.autoProvision
|
autoProvision: idp.autoProvision,
|
||||||
|
tags: idp.tags
|
||||||
})
|
})
|
||||||
.from(idp)
|
.from(idp)
|
||||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const bodySchema = z.strictObject({
|
|||||||
scopes: z.string().optional(),
|
scopes: z.string().optional(),
|
||||||
autoProvision: z.boolean().optional(),
|
autoProvision: z.boolean().optional(),
|
||||||
defaultRoleMapping: z.string().optional(),
|
defaultRoleMapping: z.string().optional(),
|
||||||
defaultOrgMapping: z.string().optional()
|
defaultOrgMapping: z.string().optional(),
|
||||||
|
tags: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateIdpResponse = {
|
export type UpdateIdpResponse = {
|
||||||
@@ -94,7 +95,8 @@ export async function updateOidcIdp(
|
|||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
defaultRoleMapping,
|
defaultRoleMapping,
|
||||||
defaultOrgMapping
|
defaultOrgMapping,
|
||||||
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
@@ -127,7 +129,8 @@ export async function updateOidcIdp(
|
|||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
defaultRoleMapping,
|
defaultRoleMapping,
|
||||||
defaultOrgMapping
|
defaultOrgMapping,
|
||||||
|
tags
|
||||||
};
|
};
|
||||||
|
|
||||||
// only update if at least one key is not undefined
|
// only update if at least one key is not undefined
|
||||||
|
|||||||
@@ -467,6 +467,14 @@ authenticated.put(
|
|||||||
role.createRole
|
role.createRole
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/role/:roleId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateRole),
|
||||||
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
|
role.updateRole
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/roles",
|
"/org/:orgId/roles",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { olms } from "@server/db";
|
import { olms, clients, fingerprints } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -17,6 +18,10 @@ const paramsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
orgId: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
// registry.registerPath({
|
// registry.registerPath({
|
||||||
// method: "get",
|
// method: "get",
|
||||||
// path: "/user/{userId}/olm/{olmId}",
|
// 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()
|
.select()
|
||||||
.from(olms)
|
.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, {
|
return response(res, {
|
||||||
data: olm,
|
data: responseData,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Successfully retrieved olm",
|
message: "Successfully retrieved olm",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@server/db";
|
|
||||||
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
|
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
|
||||||
|
import { clientPostureSnapshots, db, fingerprints } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, olms, Olm } from "@server/db";
|
import { clients, olms, Olm } from "@server/db";
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
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 { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
const { userToken } = message.data;
|
const { userToken, fingerprint, postures } = message.data;
|
||||||
|
|
||||||
if (!olm) {
|
if (!olm) {
|
||||||
logger.warn("Olm not found");
|
logger.warn("Olm not found");
|
||||||
@@ -206,6 +206,74 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
|||||||
logger.error("Error handling ping message", { error });
|
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 {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "pong",
|
type: "pong",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
|
clientPostureSnapshots,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
|
fingerprints,
|
||||||
orgs,
|
orgs,
|
||||||
siteResources
|
siteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
@@ -38,8 +40,16 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { publicKey, relay, olmVersion, olmAgent, orgId, userToken } =
|
const {
|
||||||
message.data;
|
publicKey,
|
||||||
|
relay,
|
||||||
|
olmVersion,
|
||||||
|
olmAgent,
|
||||||
|
orgId,
|
||||||
|
userToken,
|
||||||
|
fingerprint,
|
||||||
|
postures
|
||||||
|
} = message.data;
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm client ID not found");
|
logger.warn("Olm client ID not found");
|
||||||
@@ -188,6 +198,72 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
relay
|
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
|
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
||||||
// if (siteConfigurations.length === 0) {
|
// if (siteConfigurations.length === 0) {
|
||||||
// logger.warn("No valid site configurations found");
|
// logger.warn("No valid site configurations found");
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from "./listUserOlms";
|
|||||||
export * from "./getUserOlm";
|
export * from "./getUserOlm";
|
||||||
export * from "./handleOlmServerPeerAddMessage";
|
export * from "./handleOlmServerPeerAddMessage";
|
||||||
export * from "./handleOlmUnRelayMessage";
|
export * from "./handleOlmUnRelayMessage";
|
||||||
|
export * from "./recoverOlmWithFingerprint";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db } from "@server/db";
|
import { db, fingerprints } from "@server/db";
|
||||||
import { olms } from "@server/db";
|
import { olms } from "@server/db";
|
||||||
import { eq, count, desc } from "drizzle-orm";
|
import { eq, count, desc } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
limit: z
|
limit: z
|
||||||
@@ -99,22 +100,30 @@ export async function listUserOlms(
|
|||||||
const total = totalCountResult?.count || 0;
|
const total = totalCountResult?.count || 0;
|
||||||
|
|
||||||
// Get OLMs for the current user (including archived OLMs)
|
// Get OLMs for the current user (including archived OLMs)
|
||||||
const userOlms = await db
|
const list = await db
|
||||||
.select({
|
.select()
|
||||||
olmId: olms.olmId,
|
|
||||||
dateCreated: olms.dateCreated,
|
|
||||||
version: olms.version,
|
|
||||||
name: olms.name,
|
|
||||||
clientId: olms.clientId,
|
|
||||||
userId: olms.userId,
|
|
||||||
archived: olms.archived
|
|
||||||
})
|
|
||||||
.from(olms)
|
.from(olms)
|
||||||
.where(eq(olms.userId, userId))
|
.where(eq(olms.userId, userId))
|
||||||
|
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
|
||||||
.orderBy(desc(olms.dateCreated))
|
.orderBy(desc(olms.dateCreated))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.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<ListUserOlmsResponse>(res, {
|
return response<ListUserOlmsResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
olms: userOlms,
|
olms: userOlms,
|
||||||
|
|||||||
120
server/routers/olm/recoverOlmWithFingerprint.ts
Normal file
120
server/routers/olm/recoverOlmWithFingerprint.ts
Normal file
@@ -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<any> {
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||||
|
|
||||||
const createRoleParamsSchema = z.strictObject({
|
const createRoleParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -17,7 +19,8 @@ const createRoleParamsSchema = z.strictObject({
|
|||||||
|
|
||||||
const createRoleSchema = z.strictObject({
|
const createRoleSchema = z.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
description: z.string().optional()
|
description: z.string().optional(),
|
||||||
|
requireDeviceApproval: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
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) => {
|
await db.transaction(async (trx) => {
|
||||||
const newRole = await trx
|
const newRole = await trx
|
||||||
.insert(roles)
|
.insert(roles)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { db, orgs, roles } from "@server/db";
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { roles, orgs } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
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 logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import stoi from "@server/lib/stoi";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
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({
|
const listRolesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -38,7 +36,8 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
|
|||||||
isAdmin: roles.isAdmin,
|
isAdmin: roles.isAdmin,
|
||||||
name: roles.name,
|
name: roles.name,
|
||||||
description: roles.description,
|
description: roles.description,
|
||||||
orgName: orgs.name
|
orgName: orgs.name,
|
||||||
|
requireDeviceApproval: roles.requireDeviceApproval
|
||||||
})
|
})
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, orgs, type Role } from "@server/db";
|
||||||
import { roles } from "@server/db";
|
import { roles } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -8,20 +8,28 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||||
|
|
||||||
const updateRoleParamsSchema = z.strictObject({
|
const updateRoleParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string(),
|
||||||
roleId: z.string().transform(Number).pipe(z.int().positive())
|
roleId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRoleBodySchema = z
|
const updateRoleBodySchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
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, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type UpdateRoleBody = z.infer<typeof updateRoleBodySchema>;
|
||||||
|
|
||||||
|
export type UpdateRoleResponse = Role;
|
||||||
|
|
||||||
export async function updateRole(
|
export async function updateRole(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -48,13 +56,14 @@ export async function updateRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roleId } = parsedParams.data;
|
const { roleId, orgId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
const role = await db
|
const role = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(eq(roles.roleId, roleId))
|
.where(eq(roles.roleId, roleId))
|
||||||
|
.innerJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (role.length === 0) {
|
if (role.length === 0) {
|
||||||
@@ -66,7 +75,7 @@ export async function updateRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role[0].isAdmin) {
|
if (role[0].roles.isAdmin) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
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
|
const updatedRole = await db
|
||||||
.update(roles)
|
.update(roles)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|||||||
52
src/app/[orgId]/settings/(private)/access/approvals/page.tsx
Normal file
52
src/app/[orgId]/settings/(private)/access/approvals/page.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("accessApprovalsManage")}
|
||||||
|
description={t("accessApprovalsDescription")}
|
||||||
|
/>
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<ApprovalFeed orgId={params.orgId} />
|
||||||
|
</div>
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} 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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -19,29 +24,21 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} 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 { 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 { 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 { 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 { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { AxiosResponse } from "axios";
|
import { useEffect, useState } from "react";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import { cache } from "react";
|
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
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 SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
|
||||||
type RolesPageProps = {
|
type RolesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -47,14 +47,7 @@ export default async function RolesPage(props: RolesPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let org: GetOrgResponse | null = null;
|
let org: GetOrgResponse | null = null;
|
||||||
const getOrg = cache(async () =>
|
const orgRes = await getCachedOrg(params.orgId);
|
||||||
internal
|
|
||||||
.get<
|
|
||||||
AxiosResponse<GetOrgResponse>
|
|
||||||
>(`/org/${params.orgId}`, await authCookieHeader())
|
|
||||||
.catch((e) => {})
|
|
||||||
);
|
|
||||||
const orgRes = await getOrg();
|
|
||||||
|
|
||||||
if (orgRes && orgRes.status === 200) {
|
if (orgRes && orgRes.status === 200) {
|
||||||
org = orgRes.data.data;
|
org = orgRes.data.data;
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
niceId: client.niceId,
|
niceId: client.niceId,
|
||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false
|
blocked: client.blocked || false,
|
||||||
|
approvalState: client.approvalState ?? "approved"
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
import { getTranslations } from "next-intl/server";
|
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";
|
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
@@ -57,7 +57,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
niceId: client.niceId,
|
niceId: client.niceId,
|
||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false
|
blocked: client.blocked || false,
|
||||||
|
approvalState: client.approvalState
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -768,6 +768,8 @@ export default function ResourceAuthenticationPage() {
|
|||||||
<OneTimePasswordFormSection
|
<OneTimePasswordFormSection
|
||||||
resource={resource}
|
resource={resource}
|
||||||
updateResource={updateResource}
|
updateResource={updateResource}
|
||||||
|
whitelist={whitelist}
|
||||||
|
isLoadingWhiteList={isLoadingWhiteList}
|
||||||
/>
|
/>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
</>
|
</>
|
||||||
@@ -777,11 +779,16 @@ export default function ResourceAuthenticationPage() {
|
|||||||
type OneTimePasswordFormSectionProps = Pick<
|
type OneTimePasswordFormSectionProps = Pick<
|
||||||
ResourceContextType,
|
ResourceContextType,
|
||||||
"resource" | "updateResource"
|
"resource" | "updateResource"
|
||||||
>;
|
> & {
|
||||||
|
whitelist: Array<{ email: string }>;
|
||||||
|
isLoadingWhiteList: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function OneTimePasswordFormSection({
|
function OneTimePasswordFormSection({
|
||||||
resource,
|
resource,
|
||||||
updateResource
|
updateResource,
|
||||||
|
whitelist,
|
||||||
|
isLoadingWhiteList
|
||||||
}: OneTimePasswordFormSectionProps) {
|
}: OneTimePasswordFormSectionProps) {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
||||||
@@ -802,6 +809,18 @@ function OneTimePasswordFormSection({
|
|||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingWhiteList) return;
|
||||||
|
|
||||||
|
whitelistForm.setValue(
|
||||||
|
"emails",
|
||||||
|
whitelist.map((w) => ({
|
||||||
|
id: w.email,
|
||||||
|
text: w.email
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [isLoadingWhiteList, whitelist, whitelistForm]);
|
||||||
|
|
||||||
async function saveWhitelist() {
|
async function saveWhitelist() {
|
||||||
try {
|
try {
|
||||||
await api.post(`/resource/${resource.resourceId}`, {
|
await api.post(`/resource/${resource.resourceId}`, {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import SitesTable, { SiteRow } from "../../../../components/SitesTable";
|
import SitesTable, { SiteRow } from "@app/components/SitesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import SitesBanner from "@app/components/SitesBanner";
|
import SitesBanner from "@app/components/SitesBanner";
|
||||||
import SitesSplashCard from "../../../../components/SitesSplashCard";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
type SitesPageProps = {
|
type SitesPageProps = {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
className="flex items-center space-x-2 whitespace-nowrap"
|
className="flex items-center space-x-2 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<span>{t("terms")}</span>
|
<span>{t("termsOfService")}</span>
|
||||||
</a>
|
</a>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<a
|
<a
|
||||||
@@ -123,7 +123,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
className="flex items-center space-x-2 whitespace-nowrap"
|
className="flex items-center space-x-2 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<span>{t("privacy")}</span>
|
<span>{t("privacyPolicy")}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
import SmartLoginForm from "@app/components/SmartLoginForm";
|
||||||
import DashboardLoginForm from "@app/components/DashboardLoginForm";
|
import DashboardLoginForm from "@app/components/DashboardLoginForm";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
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 { getTranslations } from "next-intl/server";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -69,22 +72,57 @@ export default async function Page(props: {
|
|||||||
searchParams.redirect = redirectUrl;
|
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[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
if (build === "oss" || !env.flags.useOrgOnlyIdp) {
|
if (!useSmartLogin) {
|
||||||
const idpsRes = await cache(
|
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||||
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
if (build === "oss" || !env.flags.useOrgOnlyIdp) {
|
||||||
)();
|
const idpsRes = await cache(
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
async () =>
|
||||||
idpId: idp.idpId,
|
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
||||||
name: idp.name,
|
)();
|
||||||
variant: idp.type
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
})) as LoginFormIDP[];
|
idpId: idp.idpId,
|
||||||
|
name: idp.name,
|
||||||
|
variant: idp.type
|
||||||
|
})) as LoginFormIDP[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{build === "saas" && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center mb-4">
|
||||||
|
{t.rich("loginLegalDisclaimer", {
|
||||||
|
termsOfService: (chunks) => (
|
||||||
|
<Link
|
||||||
|
href="https://pangolin.net/terms-of-service.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
privacyPolicy: (chunks) => (
|
||||||
|
<Link
|
||||||
|
href="https://pangolin.net/privacy-policy.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{isInvite && (
|
{isInvite && (
|
||||||
<div className="border rounded-md p-3 mb-4 bg-card">
|
<div className="border rounded-md p-3 mb-4 bg-card">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@@ -99,15 +137,36 @@ export default async function Page(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardLoginForm
|
{useSmartLogin ? (
|
||||||
redirect={redirectUrl}
|
<>
|
||||||
idps={loginIdps}
|
<Card className="w-full max-w-md">
|
||||||
forceLogin={forceLogin}
|
<LoginCardHeader
|
||||||
showOrgLogin={
|
subtitle={
|
||||||
!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp)
|
forceLogin
|
||||||
}
|
? t("loginRequiredForDevice")
|
||||||
searchParams={searchParams}
|
: t("loginStart")
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<SmartLoginForm
|
||||||
|
redirect={redirectUrl}
|
||||||
|
forceLogin={forceLogin}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DashboardLoginForm
|
||||||
|
redirect={redirectUrl}
|
||||||
|
idps={loginIdps}
|
||||||
|
forceLogin={forceLogin}
|
||||||
|
showOrgLogin={
|
||||||
|
!isInvite &&
|
||||||
|
(build === "saas" || env.flags.useOrgOnlyIdp)
|
||||||
|
}
|
||||||
|
searchParams={searchParams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{(!signUpDisabled || isInvite) && (
|
{(!signUpDisabled || isInvite) && (
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
@@ -124,6 +183,31 @@ export default async function Page(props: {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? (
|
||||||
|
<OrgSignInLink
|
||||||
|
href={`/auth/org${buildQueryString(searchParams)}`}
|
||||||
|
linkText={t("orgAuthSignInToOrg")}
|
||||||
|
descriptionText={t("needToSignInToOrg")}
|
||||||
|
/>
|
||||||
|
) : 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}` : "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
email: string | undefined;
|
email: string | undefined;
|
||||||
|
fromSmartLogin: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
@@ -73,6 +74,7 @@ export default async function Page(props: {
|
|||||||
inviteToken={inviteToken}
|
inviteToken={inviteToken}
|
||||||
inviteId={inviteId}
|
inviteId={inviteId}
|
||||||
emailParam={searchParams.email}
|
emailParam={searchParams.email}
|
||||||
|
fromSmartLogin={searchParams.fromSmartLogin === "true"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.91 0.004 286.32);
|
--border: oklch(0.91 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.213 47.604);
|
--ring: oklch(0.705 0.213 47.604);
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.5382 0.1949 22.216);
|
--destructive: oklch(0.5382 0.1949 22.216);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(1 0 0 / 13%);
|
--border: oklch(1 0 0 / 13%);
|
||||||
--input: oklch(1 0 0 / 18%);
|
--input: oklch(1 0 0 / 18%);
|
||||||
--ring: oklch(0.646 0.222 41.116);
|
--ring: oklch(0.646 0.222 41.116);
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
|||||||
import { Env } from "@app/lib/types/env";
|
import { Env } from "@app/lib/types/env";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
Settings,
|
ChartLine,
|
||||||
Users,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Waypoints,
|
|
||||||
Combine,
|
Combine,
|
||||||
|
CreditCard,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
|
Globe,
|
||||||
|
GlobeLock,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Laptop,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Logs, // Added from 'dev' branch
|
||||||
|
MonitorUp,
|
||||||
|
ReceiptText,
|
||||||
|
ScanEye, // Added from 'dev' branch
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
SquareMousePointer,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
User,
|
User,
|
||||||
Globe, // Added from 'dev' branch
|
UserCog,
|
||||||
MonitorUp, // Added from 'dev' branch
|
Users,
|
||||||
Server,
|
Waypoints
|
||||||
ReceiptText,
|
|
||||||
CreditCard,
|
|
||||||
Logs,
|
|
||||||
SquareMousePointer,
|
|
||||||
ScanEye,
|
|
||||||
GlobeLock,
|
|
||||||
Smartphone,
|
|
||||||
Laptop,
|
|
||||||
ChartLine
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = {
|
||||||
@@ -123,7 +123,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
href: "/{orgId}/settings/access/roles",
|
href: "/{orgId}/settings/access/roles",
|
||||||
icon: <Users className="size-4 flex-none" />
|
icon: <Users className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "saas" || env?.flags.useOrgOnlyIdp
|
...(build === "saas" || env?.flags.useOrgOnlyIdp
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarIdentityProviders",
|
title: "sidebarIdentityProviders",
|
||||||
@@ -132,6 +132,15 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(build !== "oss"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "sidebarApprovals",
|
||||||
|
href: "/{orgId}/settings/access/approvals",
|
||||||
|
icon: <UserCog className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
title: "sidebarShareableLinks",
|
title: "sidebarShareableLinks",
|
||||||
href: "/{orgId}/settings/share-links",
|
href: "/{orgId}/settings/share-links",
|
||||||
|
|||||||
243
src/components/ApprovalFeed.tsx
Normal file
243
src/components/ApprovalFeed.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card className="">
|
||||||
|
<CardHeader className="flex flex-col sm:flex-row sm:items-end lg:items-end gap-2 ">
|
||||||
|
<div className="flex flex-col items-start gap-2 w-48 mb-0">
|
||||||
|
<Label htmlFor="approvalState">
|
||||||
|
{t("filterByApprovalState")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
const newSearch = new URLSearchParams(
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
newSearch.set("approvalState", newValue);
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`${path}?${newSearch.toString()}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={filters.approvalState ?? "all"}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="approvalState"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("selectApprovalState")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-full">
|
||||||
|
<SelectItem value="pending">
|
||||||
|
{t("pending")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="approved">
|
||||||
|
{t("approved")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="denied">
|
||||||
|
{t("denied")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="all">{t("all")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="lg:static gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
isFetching && "animate-spin"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t("refresh")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{approvals.map((approval, index) => (
|
||||||
|
<Fragment key={approval.approvalId}>
|
||||||
|
<li>
|
||||||
|
<ApprovalRequest
|
||||||
|
approval={approval}
|
||||||
|
orgId={orgId}
|
||||||
|
onSuccess={() => refetch()}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{index < approvals.length - 1 && <Separator />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{approvals.length === 0 && (
|
||||||
|
<li className="flex justify-center items-center p-4 text-muted-foreground">
|
||||||
|
{t("approvalListEmpty")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="inline-flex items-start md:items-center gap-2">
|
||||||
|
<LaptopMinimal className="size-4 text-muted-foreground flex-none relative top-2 sm:top-0" />
|
||||||
|
<span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{approval.user.username}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{approval.type === "user_device" && (
|
||||||
|
<span>{t("requestingNewDeviceApproval")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{approval.decision === "pending" && (
|
||||||
|
<form action={formAction} className="inline-flex gap-2">
|
||||||
|
<Button
|
||||||
|
value="approved"
|
||||||
|
name="decision"
|
||||||
|
className="gap-2"
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
<Check className="size-4 flex-none" />
|
||||||
|
{t("approve")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
value="denied"
|
||||||
|
name="decision"
|
||||||
|
variant="destructive"
|
||||||
|
className="gap-2"
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
<Ban className="size-4 flex-none" />
|
||||||
|
{t("deny")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{approval.decision === "approved" && (
|
||||||
|
<Badge variant="green">{t("approved")}</Badge>
|
||||||
|
)}
|
||||||
|
{approval.decision === "denied" && (
|
||||||
|
<Badge variant="red">{t("denied")}</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {}}
|
||||||
|
className="gap-2"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={"#"}>
|
||||||
|
{t("viewDetails")}
|
||||||
|
<ArrowRight className="size-4 flex-none" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -26,17 +10,37 @@ import {
|
|||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
import {
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
Form,
|
||||||
import { createApiClient } from "@app/lib/api";
|
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 { 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 { 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 = {
|
type CreateRoleFormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
|
afterCreate?: (res: CreateRoleResponse) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateRoleForm({
|
export default function CreateRoleForm({
|
||||||
@@ -46,35 +50,35 @@ export default function CreateRoleForm({
|
|||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string({ message: t("nameRequired") }).max(32),
|
name: z
|
||||||
description: z.string().max(255).optional()
|
.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 api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: ""
|
description: "",
|
||||||
|
requireDeviceApproval: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
const [loading, startTransition] = useTransition();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<AxiosResponse<CreateRoleResponse>>(
|
.put<
|
||||||
`/org/${org?.org.orgId}/role`,
|
AxiosResponse<CreateRoleResponse>
|
||||||
{
|
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
|
||||||
name: values.name,
|
|
||||||
description: values.description
|
|
||||||
} as CreateRoleBody
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -97,12 +101,8 @@ export default function CreateRoleForm({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (afterCreate) {
|
afterCreate?.(res.data.data);
|
||||||
afterCreate(res.data.data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,7 +111,6 @@ export default function CreateRoleForm({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
setOpen(val);
|
setOpen(val);
|
||||||
setLoading(false);
|
|
||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -125,7 +124,9 @@ export default function CreateRoleForm({
|
|||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit((values) =>
|
||||||
|
startTransition(() => onSubmit(values))
|
||||||
|
)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-role-form"
|
id="create-role-form"
|
||||||
>
|
>
|
||||||
@@ -159,6 +160,56 @@ export default function CreateRoleForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{build !== "oss" && (
|
||||||
|
<div className="pt-3">
|
||||||
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requireDeviceApproval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="my-2">
|
||||||
|
<FormControl>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
!isPaidUser
|
||||||
|
}
|
||||||
|
value="on"
|
||||||
|
checked={form.watch(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
checked !==
|
||||||
|
"indeterminate"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"requireDeviceApproval",
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"requireDeviceApprovalDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -14,16 +12,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger
|
DialogTrigger
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { DrawerClose } from "@/components/ui/drawer";
|
||||||
Drawer,
|
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||||
DrawerClose,
|
import { cn } from "@app/lib/cn";
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -78,10 +69,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CredenzaClose
|
<CredenzaClose className={cn("", className)} {...props}>
|
||||||
className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
);
|
);
|
||||||
@@ -172,14 +160,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
|
|
||||||
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||||
const isDesktop = useMediaQuery(desktop);
|
const isDesktop = useMediaQuery(desktop);
|
||||||
// const isDesktop = true;
|
|
||||||
|
|
||||||
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CredenzaFooter
|
<CredenzaFooter
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-8 md:mt-0 -mx-6 px-6 pt-4 border-t border-border",
|
"mt-8 md:mt-0 -mx-6 px-6 py-4 border-t border-border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -191,12 +178,12 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaTrigger,
|
CredenzaBody,
|
||||||
CredenzaClose,
|
CredenzaClose,
|
||||||
CredenzaContent,
|
CredenzaContent,
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle,
|
CredenzaTitle,
|
||||||
CredenzaBody,
|
CredenzaTrigger
|
||||||
CredenzaFooter
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,22 +69,6 @@ export default function DashboardLoginForm({
|
|||||||
<div className="text-center space-y-1 pt-3">
|
<div className="text-center space-y-1 pt-3">
|
||||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||||
</div>
|
</div>
|
||||||
{showOrgLogin && (
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
<Link
|
|
||||||
href={`/auth/org${buildQueryString(searchParams || {})}`}
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full gap-2"
|
|
||||||
>
|
|
||||||
{t("orgAuthSignInToOrg")}
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
@@ -104,20 +88,3 @@ export default function DashboardLoginForm({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}` : "";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@/components/ui/form";
|
} 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 { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
@@ -25,12 +31,12 @@ import {
|
|||||||
InputOTPSlot
|
InputOTPSlot
|
||||||
} from "@/components/ui/input-otp";
|
} from "@/components/ui/input-otp";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "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 { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import BrandingLogo from "./BrandingLogo";
|
import BrandingLogo from "./BrandingLogo";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import UserProfileCard from "@/components/UserProfileCard";
|
||||||
|
|
||||||
const createFormSchema = (t: (key: string) => string) =>
|
const createFormSchema = (t: (key: string) => string) =>
|
||||||
z.object({
|
z.object({
|
||||||
@@ -61,6 +67,8 @@ export default function DeviceLoginForm({
|
|||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [validatingInitialCode, setValidatingInitialCode] = useState(false);
|
||||||
|
const [verifyingInitialCode, setVerifyingInitialCode] = useState(false);
|
||||||
const [metadata, setMetadata] = useState<DeviceAuthMetadata | null>(null);
|
const [metadata, setMetadata] = useState<DeviceAuthMetadata | null>(null);
|
||||||
const [code, setCode] = useState<string>("");
|
const [code, setCode] = useState<string>("");
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
@@ -75,39 +83,88 @@ export default function DeviceLoginForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
const validateCode = useCallback(
|
||||||
setError(null);
|
async (codeToValidate: string, skipConfirmation = false) => {
|
||||||
setLoading(true);
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// split code and add dash if missing
|
// split code and add dash if missing
|
||||||
if (!data.code.includes("-") && data.code.length === 8) {
|
let formattedCode = codeToValidate;
|
||||||
data.code = data.code.slice(0, 4) + "-" + data.code.slice(4);
|
if (
|
||||||
}
|
!formattedCode.includes("-") &&
|
||||||
|
formattedCode.length === 8
|
||||||
// First check - get metadata
|
) {
|
||||||
const res = await api.post(
|
formattedCode =
|
||||||
"/device-web-auth/verify?forceLogin=true",
|
formattedCode.slice(0, 4) +
|
||||||
{
|
"-" +
|
||||||
code: data.code.toUpperCase(),
|
formattedCode.slice(4);
|
||||||
verify: false
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (res.data.success && res.data.data.metadata) {
|
// First check - get metadata
|
||||||
setMetadata(res.data.data.metadata);
|
const res = await api.post(
|
||||||
setCode(data.code.toUpperCase());
|
"/device-web-auth/verify?forceLogin=true",
|
||||||
} else {
|
{
|
||||||
setError(t("deviceCodeInvalidOrExpired"));
|
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);
|
[api, t, router]
|
||||||
setError(errorMessage || t("deviceCodeInvalidOrExpired"));
|
);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||||
}
|
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() {
|
async function onConfirm() {
|
||||||
if (!code || !metadata) return;
|
if (!code || !metadata) return;
|
||||||
|
|
||||||
@@ -149,9 +206,6 @@ export default function DeviceLoginForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const profileLabel = (userName || userEmail || "").trim();
|
const profileLabel = (userName || userEmail || "").trim();
|
||||||
const profileInitial = profileLabel
|
|
||||||
? profileLabel.charAt(0).toUpperCase()
|
|
||||||
: "?";
|
|
||||||
|
|
||||||
async function handleUseDifferentAccount() {
|
async function handleUseDifferentAccount() {
|
||||||
try {
|
try {
|
||||||
@@ -172,6 +226,39 @@ export default function DeviceLoginForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state while validating/verifying initial code
|
||||||
|
if (validatingInitialCode || verifyingInitialCode) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("deviceActivation")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{validatingInitialCode
|
||||||
|
? t("deviceCodeValidating")
|
||||||
|
: t("deviceCodeVerifying")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>
|
||||||
|
{validatingInitialCode
|
||||||
|
? t("deviceCodeValidating")
|
||||||
|
: t("deviceCodeVerifying")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="w-full">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
return (
|
return (
|
||||||
<DeviceAuthConfirmation
|
<DeviceAuthConfirmation
|
||||||
@@ -195,32 +282,17 @@ export default function DeviceLoginForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6 space-y-4">
|
||||||
<div className="flex items-center gap-3 p-3 mb-4 border rounded-md">
|
<UserProfileCard
|
||||||
<Avatar className="h-10 w-10">
|
identifier={profileLabel || userEmail}
|
||||||
<AvatarFallback>{profileInitial}</AvatarFallback>
|
description={t(
|
||||||
</Avatar>
|
"deviceLoginDeviceRequestingAccessToAccount"
|
||||||
<div className="flex-1 space-y-1">
|
)}
|
||||||
<div>
|
onUseDifferentAccount={handleUseDifferentAccount}
|
||||||
<p className="text-sm font-medium">
|
useDifferentAccountText={t(
|
||||||
{profileLabel || userEmail}
|
"deviceLoginUseDifferentAccount"
|
||||||
</p>
|
)}
|
||||||
<p className="text-xs text-muted-foreground break-all">
|
/>
|
||||||
{t(
|
|
||||||
"deviceLoginDeviceRequestingAccessToAccount"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
className="h-auto px-0 text-xs"
|
|
||||||
onClick={handleUseDifferentAccount}
|
|
||||||
>
|
|
||||||
{t("deviceLoginUseDifferentAccount")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ export const DismissableBanner = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mb-6 relative border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background overflow-hidden">
|
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors"
|
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
||||||
aria-label={t("dismiss")}
|
aria-label={t("dismiss")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-muted-foreground" />
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -91,7 +91,7 @@ export const DismissableBanner = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="flex flex-wrap gap-3 lg:flex-shrink-0 lg:justify-end">
|
<div className="flex flex-wrap gap-3 lg:shrink-0 lg:justify-end">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
241
src/components/EditRoleForm.tsx
Normal file
241
src/components/EditRoleForm.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import type { Role } from "@server/db";
|
||||||
|
import type {
|
||||||
|
CreateRoleBody,
|
||||||
|
CreateRoleResponse,
|
||||||
|
UpdateRoleBody,
|
||||||
|
UpdateRoleResponse
|
||||||
|
} from "@server/routers/role";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||||
|
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||||
|
|
||||||
|
type CreateRoleFormProps = {
|
||||||
|
role: Role;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onSuccess?: (res: CreateRoleResponse) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditRoleForm({
|
||||||
|
open,
|
||||||
|
role,
|
||||||
|
setOpen,
|
||||||
|
onSuccess
|
||||||
|
}: CreateRoleFormProps) {
|
||||||
|
const { org } = useOrgContext();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({ message: t("nameRequired") })
|
||||||
|
.min(1)
|
||||||
|
.max(32),
|
||||||
|
description: z.string().max(255).optional(),
|
||||||
|
requireDeviceApproval: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
requireDeviceApproval: role.requireDeviceApproval ?? false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
const res = await api
|
||||||
|
.post<
|
||||||
|
AxiosResponse<UpdateRoleResponse>
|
||||||
|
>(`/org/${org?.org.orgId}/role/${role.roleId}`, values satisfies UpdateRoleBody)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("accessRoleErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("accessRoleErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("accessRoleUpdated"),
|
||||||
|
description: t("accessRoleUpdatedDescription")
|
||||||
|
});
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.(res.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("accessRoleEditDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((values) =>
|
||||||
|
startTransition(() => onSubmit(values))
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="create-role-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("accessRoleName")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("description")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{build !== "oss" && (
|
||||||
|
<div className="pt-3">
|
||||||
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requireDeviceApproval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="my-2">
|
||||||
|
<FormControl>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
!isPaidUser
|
||||||
|
}
|
||||||
|
value="on"
|
||||||
|
checked={form.watch(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
checked !==
|
||||||
|
"indeterminate"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"requireDeviceApproval",
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"requireDeviceApprovalDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="create-role-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("accessRoleUpdateSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/LoginCardHeader.tsx
Normal file
33
src/components/LoginCardHeader.tsx
Normal file
@@ -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 (
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<div className="flex flex-row items-center justify-center">
|
||||||
|
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-1 pt-3">
|
||||||
|
<p className="text-muted-foreground">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
@@ -23,32 +23,24 @@ import {
|
|||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { createApiClient } from "@app/lib/api";
|
||||||
import {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSeparator,
|
|
||||||
InputOTPSlot
|
|
||||||
} from "./ui/input-otp";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { startAuthentication } from "@simplewebauthn/browser";
|
|
||||||
import {
|
import {
|
||||||
generateOidcUrlProxy,
|
generateOidcUrlProxy,
|
||||||
loginProxy,
|
loginProxy
|
||||||
securityKeyStartProxy,
|
|
||||||
securityKeyVerifyProxy
|
|
||||||
} from "@app/actions/server";
|
} from "@app/actions/server";
|
||||||
import { redirect as redirectTo } from "next/navigation";
|
import { redirect as redirectTo } from "next/navigation";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { loadReoScript } from "reodotdev";
|
import { loadReoScript } from "reodotdev";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
@@ -83,8 +75,6 @@ export default function LoginForm({
|
|||||||
const hasIdp = idps && idps.length > 0;
|
const hasIdp = idps && idps.length > 0;
|
||||||
|
|
||||||
const [mfaRequested, setMfaRequested] = useState(false);
|
const [mfaRequested, setMfaRequested] = useState(false);
|
||||||
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
|
||||||
const otpContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const currentHost =
|
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({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t("emailInvalid") }),
|
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) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
@@ -282,7 +135,6 @@ export default function LoginForm({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setShowSecurityKeyPrompt(false);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await loginProxy(
|
const response = await loginProxy(
|
||||||
@@ -323,7 +175,12 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.useSecurityKey) {
|
if (data.useSecurityKey) {
|
||||||
await initiateSecurityKeyAuth();
|
setError(
|
||||||
|
t("securityKeyRequired", {
|
||||||
|
defaultValue:
|
||||||
|
"Please use your security key to sign in."
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,18 +266,6 @@ export default function LoginForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{showSecurityKeyPrompt && (
|
|
||||||
<Alert>
|
|
||||||
<FingerprintIcon className="w-5 h-5 mr-2" />
|
|
||||||
<AlertDescription>
|
|
||||||
{t("securityKeyPrompt", {
|
|
||||||
defaultValue:
|
|
||||||
"Please verify your identity using your security key. Make sure your security key is connected and ready."
|
|
||||||
})}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -488,115 +333,36 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaRequested && (
|
{mfaRequested && (
|
||||||
<>
|
<MfaInputForm
|
||||||
<div className="text-center">
|
form={mfaForm}
|
||||||
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
onSubmit={onSubmit}
|
||||||
<p className="text-sm text-muted-foreground">
|
onBack={() => {
|
||||||
{t("otpAuthDescription")}
|
setMfaRequested(false);
|
||||||
</p>
|
mfaForm.reset();
|
||||||
</div>
|
}}
|
||||||
<Form {...mfaForm}>
|
error={error}
|
||||||
<form
|
loading={loading}
|
||||||
onSubmit={mfaForm.handleSubmit(onSubmit)}
|
formId="form"
|
||||||
className="space-y-4"
|
/>
|
||||||
id="form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={mfaForm.control}
|
|
||||||
name="code"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div
|
|
||||||
ref={otpContainerRef}
|
|
||||||
className="flex justify-center"
|
|
||||||
>
|
|
||||||
<InputOTP
|
|
||||||
maxLength={6}
|
|
||||||
{...field}
|
|
||||||
autoFocus
|
|
||||||
pattern={
|
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
value: string
|
|
||||||
) => {
|
|
||||||
field.onChange(value);
|
|
||||||
if (
|
|
||||||
value.length === 6
|
|
||||||
) {
|
|
||||||
mfaForm.handleSubmit(
|
|
||||||
onSubmit
|
|
||||||
)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={0}
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={1}
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={2}
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={3}
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={4}
|
|
||||||
/>
|
|
||||||
<InputOTPSlot
|
|
||||||
index={5}
|
|
||||||
/>
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{!mfaRequested && error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{mfaRequested && (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="form"
|
|
||||||
className="w-full"
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("otpAuthSubmit")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<SecurityKeyAuthButton
|
||||||
type="button"
|
redirect={redirect}
|
||||||
variant="outline"
|
forceLogin={forceLogin}
|
||||||
className="w-full"
|
onSuccess={onLogin}
|
||||||
onClick={initiateSecurityKeyAuth}
|
onError={setError}
|
||||||
loading={loading}
|
disabled={loading}
|
||||||
disabled={loading || showSecurityKeyPrompt}
|
/>
|
||||||
>
|
|
||||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
|
||||||
{t("securityKeyLogin", {
|
|
||||||
defaultValue: "Sign in with security key"
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{hasIdp && (
|
{hasIdp && (
|
||||||
<>
|
<>
|
||||||
@@ -652,19 +418,6 @@ export default function LoginForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaRequested && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setMfaRequested(false);
|
|
||||||
mfaForm.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("otpAuthBack")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
155
src/components/LoginOrgSelector.tsx
Normal file
155
src/components/LoginOrgSelector.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<UserProfileCard
|
||||||
|
identifier={identifier}
|
||||||
|
description={t("loginSelectAuthenticationMethod")}
|
||||||
|
onUseDifferentAccount={onUseDifferentAccount}
|
||||||
|
useDifferentAccountText={t(
|
||||||
|
"deviceLoginUseDifferentAccount"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<LoginPasswordForm
|
||||||
|
identifier={identifier}
|
||||||
|
redirect={redirect}
|
||||||
|
forceLogin={forceLogin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UserProfileCard
|
||||||
|
identifier={identifier}
|
||||||
|
description={t("loginSelectAuthenticationMethod")}
|
||||||
|
onUseDifferentAccount={onUseDifferentAccount}
|
||||||
|
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasInternalAccount && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setShowPasswordForm(true)}
|
||||||
|
>
|
||||||
|
{t("signInWithPassword")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-0 mt-3">
|
||||||
|
{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 (
|
||||||
|
<div key={org.orgId}>
|
||||||
|
<div className="py-3">
|
||||||
|
<h3 className="text-base font-semibold mb-3">
|
||||||
|
{org.orgName}
|
||||||
|
</h3>
|
||||||
|
<IdpLoginButtons
|
||||||
|
idps={idps}
|
||||||
|
redirect={redirect}
|
||||||
|
orgId={org.orgId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/components/LoginPasswordForm.tsx
Normal file
326
src/components/LoginPasswordForm.tsx
Normal file
@@ -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<string | null>(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<typeof formSchema>) {
|
||||||
|
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<typeof mfaSchema>) {
|
||||||
|
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 (
|
||||||
|
<MfaInputForm
|
||||||
|
form={mfaForm}
|
||||||
|
onSubmit={onMfaSubmit}
|
||||||
|
onBack={() => {
|
||||||
|
setMfaRequested(false);
|
||||||
|
mfaForm.reset();
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("password")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href={`${env.app.dashboardUrl}/auth/reset-password${isEmail ? `?email=${encodeURIComponent(identifier)}` : ""}${redirect ? `${isEmail ? "&" : "?"}redirect=${encodeURIComponent(redirect)}` : ""}`}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("passwordForgot")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t("logIn")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
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 { Button } from "@app/components/ui/button";
|
||||||
|
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -16,7 +15,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ArrowUpRight,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
CircleSlash
|
CircleSlash
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -25,7 +23,6 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { InfoPopup } from "./ui/info-popup";
|
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,6 +42,7 @@ export type ClientRow = {
|
|||||||
agent: string | null;
|
agent: string | null;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
|
approvalState: "approved" | "pending" | "denied";
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
@@ -214,7 +212,10 @@ export default function MachineClientsTable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{r.blocked && (
|
{r.blocked && (
|
||||||
<Badge variant="destructive" className="flex items-center gap-1">
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
<CircleSlash className="h-3 w-3" />
|
<CircleSlash className="h-3 w-3" />
|
||||||
{t("blocked")}
|
{t("blocked")}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -410,7 +411,9 @@ export default function MachineClientsTable({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{clientRow.archived ? "Unarchive" : "Archive"}
|
{clientRow.archived
|
||||||
|
? "Unarchive"
|
||||||
|
: "Archive"}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -424,7 +427,9 @@ export default function MachineClientsTable({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{clientRow.blocked ? "Unblock" : "Block"}
|
{clientRow.blocked
|
||||||
|
? "Unblock"
|
||||||
|
: "Block"}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -539,15 +544,27 @@ export default function MachineClientsTable({
|
|||||||
value: "blocked"
|
value: "blocked"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
|
filterFn: (
|
||||||
|
row: ClientRow,
|
||||||
|
selectedValues: (string | number | boolean)[]
|
||||||
|
) => {
|
||||||
if (selectedValues.length === 0) return true;
|
if (selectedValues.length === 0) return true;
|
||||||
const rowArchived = row.archived || false;
|
const rowArchived = row.archived || false;
|
||||||
const rowBlocked = row.blocked || false;
|
const rowBlocked = row.blocked || false;
|
||||||
const isActive = !rowArchived && !rowBlocked;
|
const isActive = !rowArchived && !rowBlocked;
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive) return true;
|
if (selectedValues.includes("active") && isActive)
|
||||||
if (selectedValues.includes("archived") && rowArchived) return true;
|
return true;
|
||||||
if (selectedValues.includes("blocked") && rowBlocked) return true;
|
if (
|
||||||
|
selectedValues.includes("archived") &&
|
||||||
|
rowArchived
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
selectedValues.includes("blocked") &&
|
||||||
|
rowBlocked
|
||||||
|
)
|
||||||
|
return true;
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
defaultValues: ["active"] // Default to showing active clients
|
defaultValues: ["active"] // Default to showing active clients
|
||||||
|
|||||||
169
src/components/MfaInputForm.tsx
Normal file
169
src/components/MfaInputForm.tsx
Normal file
@@ -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<void>;
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("otpAuthDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
id={formId}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div
|
||||||
|
ref={otpContainerRef}
|
||||||
|
className="flex justify-center"
|
||||||
|
>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
autoFocus
|
||||||
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.onChange(value);
|
||||||
|
if (value.length === 6) {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
className="w-full"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("otpAuthSubmit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
{t("otpAuthBack")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -116,6 +116,14 @@ export default async function OrgLoginPage({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
|
<Link
|
||||||
|
href={`${env.app.dashboardUrl}/auth/login${buildQueryString(searchParams)}`}
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{t("loginBack")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/components/OrgSignInLink.tsx
Normal file
107
src/components/OrgSignInLink.tsx
Normal file
@@ -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 && (
|
||||||
|
<Alert className="mb-4 mt-8">
|
||||||
|
<AlertTitle>{t("orgSignInNotice")}</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-3 mt-3">
|
||||||
|
<p>{t("orgSignInTip")}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleDontShowAgain}
|
||||||
|
>
|
||||||
|
{t("dontShowAgain")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleContinueAnyway}
|
||||||
|
>
|
||||||
|
{t("continueAnyway")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-center text-muted-foreground mt-8 flex flex-col items-center">
|
||||||
|
<span>{descriptionText}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
{linkText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
"use client";
|
"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 CreateRoleForm from "@app/components/CreateRoleForm";
|
||||||
import DeleteRoleForm from "@app/components/DeleteRoleForm";
|
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 { 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 { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
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;
|
export type RoleRow = Role;
|
||||||
|
|
||||||
@@ -29,27 +29,26 @@ type RolesTableProps = {
|
|||||||
roles: RoleRow[];
|
roles: RoleRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({ roles: r }: RolesTableProps) {
|
export default function UsersTable({ roles }: RolesTableProps) {
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<RoleRow | null>(null);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [roles, setRoles] = useState<RoleRow[]>(r);
|
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
|
||||||
|
|
||||||
const [roleToRemove, setUserToRemove] = useState<RoleRow | null>(null);
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -57,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
description: t("refreshError"),
|
description: t("refreshError"),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,26 +83,74 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
friendlyName: t("description"),
|
friendlyName: t("description"),
|
||||||
header: () => <span className="p-3">{t("description")}</span>
|
header: () => <span className="p-3">{t("description")}</span>
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// id: "actions",
|
||||||
|
// enableHiding: false,
|
||||||
|
// header: () => <span className="p-3"></span>,
|
||||||
|
// cell: ({ row }) => {
|
||||||
|
// const roleRow = row.original;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="flex items-center gap-2 justify-end">
|
||||||
|
// <Button
|
||||||
|
// variant={"outline"}
|
||||||
|
// disabled={roleRow.isAdmin || false}
|
||||||
|
// onClick={() => {
|
||||||
|
// setIsDeleteModalOpen(true);
|
||||||
|
// setUserToRemove(roleRow);
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {t("accessRoleDelete")}
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="p-3"></span>,
|
header: () => <span className="p-3"></span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const roleRow = row.original;
|
const roleRow = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 justify-end">
|
!roleRow.isAdmin && (
|
||||||
<Button
|
<div className="flex items-center gap-2 justify-end">
|
||||||
variant={"outline"}
|
<DropdownMenu>
|
||||||
disabled={roleRow.isAdmin || false}
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => {
|
<Button
|
||||||
setIsDeleteModalOpen(true);
|
variant="ghost"
|
||||||
setUserToRemove(roleRow);
|
className="h-8 w-8 p-0"
|
||||||
}}
|
>
|
||||||
>
|
<span className="sr-only">
|
||||||
{t("accessRoleDelete")}
|
{t("openMenu")}
|
||||||
</Button>
|
</span>
|
||||||
</div>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRoleToRemove(roleRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRole(roleRow);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,11 +158,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{editingRole && (
|
||||||
|
<EditRoleForm
|
||||||
|
role={editingRole}
|
||||||
|
open={isEditDialogOpen}
|
||||||
|
key={editingRole.roleId}
|
||||||
|
setOpen={setIsEditDialogOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Delay refresh to allow modal to close smoothly
|
||||||
|
setTimeout(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await refreshData().then(() =>
|
||||||
|
setEditingRole(null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<CreateRoleForm
|
<CreateRoleForm
|
||||||
open={isCreateModalOpen}
|
open={isCreateModalOpen}
|
||||||
setOpen={setIsCreateModalOpen}
|
setOpen={setIsCreateModalOpen}
|
||||||
afterCreate={async (role) => {
|
afterCreate={() => {
|
||||||
setRoles((prev) => [...prev, role]);
|
startTransition(refreshData);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -127,10 +190,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
setOpen={setIsDeleteModalOpen}
|
setOpen={setIsDeleteModalOpen}
|
||||||
roleToDelete={roleToRemove}
|
roleToDelete={roleToRemove}
|
||||||
afterDelete={() => {
|
afterDelete={() => {
|
||||||
setRoles((prev) =>
|
startTransition(async () => {
|
||||||
prev.filter((r) => r.roleId !== roleToRemove.roleId)
|
await refreshData().then(() =>
|
||||||
);
|
setRoleToRemove(null)
|
||||||
setUserToRemove(null);
|
);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -141,7 +205,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
createRole={() => {
|
createRole={() => {
|
||||||
setIsCreateModalOpen(true);
|
setIsCreateModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onRefresh={refreshData}
|
onRefresh={() => startTransition(refreshData)}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
157
src/components/SecurityKeyAuthButton.tsx
Normal file
157
src/components/SecurityKeyAuthButton.tsx
Normal file
@@ -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<void>;
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={className || "w-full"}
|
||||||
|
onClick={initiateSecurityKeyAuth}
|
||||||
|
disabled={externalDisabled || loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||||
|
{t("securityKeyLogin")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
FormMessage
|
FormMessage
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
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 { Progress } from "@/components/ui/progress";
|
||||||
import { SignUpResponse } from "@server/routers/auth";
|
import { SignUpResponse } from "@server/routers/auth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -70,6 +71,7 @@ type SignupFormProps = {
|
|||||||
inviteId?: string;
|
inviteId?: string;
|
||||||
inviteToken?: string;
|
inviteToken?: string;
|
||||||
emailParam?: string;
|
emailParam?: string;
|
||||||
|
fromSmartLogin?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -100,7 +102,8 @@ export default function SignupForm({
|
|||||||
redirect,
|
redirect,
|
||||||
inviteId,
|
inviteId,
|
||||||
inviteToken,
|
inviteToken,
|
||||||
emailParam
|
emailParam,
|
||||||
|
fromSmartLogin = false
|
||||||
}: SignupFormProps) {
|
}: SignupFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -201,8 +204,28 @@ export default function SignupForm({
|
|||||||
? env.branding.logo?.authPage?.height || 58
|
? env.branding.logo?.authPage?.height || 58
|
||||||
: 58;
|
: 58;
|
||||||
|
|
||||||
|
const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp);
|
||||||
|
const orgBannerHref = redirect
|
||||||
|
? `/auth/org?redirect=${encodeURIComponent(redirect)}`
|
||||||
|
: "/auth/org";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<>
|
||||||
|
{showOrgBanner && (
|
||||||
|
<Alert className="mb-4 w-full max-w-md">
|
||||||
|
<AlertTitle>{t("signupOrgNotice")}</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-2 mt-3">
|
||||||
|
<p>{t("signupOrgTip")}</p>
|
||||||
|
<Link
|
||||||
|
href={orgBannerHref}
|
||||||
|
className="text-sm font-medium underline"
|
||||||
|
>
|
||||||
|
{t("signupOrgLink")}
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<div className="flex flex-row items-center justify-center">
|
<div className="flex flex-row items-center justify-center">
|
||||||
<BrandingLogo height={logoHeight} width={logoWidth} />
|
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||||
@@ -581,9 +604,10 @@ export default function SignupForm({
|
|||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full">
|
||||||
{t("createAccount")}
|
{t("createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/components/SmartLoginForm.tsx
Normal file
232
src/components/SmartLoginForm.tsx
Normal file
@@ -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<ViewState>({ type: "initial" });
|
||||||
|
const [securityKeyError, setSecurityKeyError] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof identifierSchema>>({
|
||||||
|
resolver: zodResolver(identifierSchema),
|
||||||
|
defaultValues: {
|
||||||
|
identifier: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLookup = async (values: z.infer<typeof identifierSchema>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<UserProfileCard
|
||||||
|
identifier={viewState.identifier}
|
||||||
|
description={t("loginSelectAuthenticationMethod")}
|
||||||
|
onUseDifferentAccount={handleBack}
|
||||||
|
useDifferentAccountText={t(
|
||||||
|
"deviceLoginUseDifferentAccount"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<LoginPasswordForm
|
||||||
|
identifier={viewState.identifier}
|
||||||
|
redirect={redirect}
|
||||||
|
forceLogin={forceLogin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState.type === "orgSelector") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LoginOrgSelector
|
||||||
|
identifier={viewState.identifier}
|
||||||
|
lookupResult={viewState.lookupResult}
|
||||||
|
redirect={redirect}
|
||||||
|
forceLogin={forceLogin}
|
||||||
|
onUseDifferentAccount={handleBack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial view
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleLookup)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="identifier"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("usernameOrEmail")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(error || securityKeyError) && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{error || securityKeyError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t("continue")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SecurityKeyAuthButton
|
||||||
|
redirect={redirect}
|
||||||
|
forceLogin={forceLogin}
|
||||||
|
onError={setSecurityKeyError}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
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 { Button } from "@app/components/ui/button";
|
||||||
|
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -24,9 +23,11 @@ import { useTranslations } from "next-intl";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Badge } from "./ui/badge";
|
|
||||||
import { InfoPopup } from "./ui/info-popup";
|
|
||||||
import ClientDownloadBanner from "./ClientDownloadBanner";
|
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 = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,6 +45,7 @@ export type ClientRow = {
|
|||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
agent: string | null;
|
agent: string | null;
|
||||||
|
approvalState: "approved" | "pending" | "denied" | null;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
};
|
};
|
||||||
@@ -210,11 +212,22 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{r.blocked && (
|
{r.blocked && (
|
||||||
<Badge variant="destructive" className="flex items-center gap-1">
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
<CircleSlash className="h-3 w-3" />
|
<CircleSlash className="h-3 w-3" />
|
||||||
{t("blocked")}
|
{t("blocked")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{r.approvalState === "pending" && (
|
||||||
|
<Badge
|
||||||
|
variant="outlinePrimary"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t("pendingApproval")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,33 +285,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// accessorKey: "siteName",
|
|
||||||
// header: ({ column }) => {
|
|
||||||
// return (
|
|
||||||
// <Button
|
|
||||||
// variant="ghost"
|
|
||||||
// onClick={() =>
|
|
||||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
// }
|
|
||||||
// >
|
|
||||||
// Site
|
|
||||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
// </Button>
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// cell: ({ row }) => {
|
|
||||||
// const r = row.original;
|
|
||||||
// return (
|
|
||||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
|
||||||
// <Button variant="outline">
|
|
||||||
// {r.siteName}
|
|
||||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
// </Button>
|
|
||||||
// </Link>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: "Connectivity",
|
friendlyName: "Connectivity",
|
||||||
@@ -460,7 +446,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{clientRow.archived ? "Unarchive" : "Archive"}</span>
|
<span>
|
||||||
|
{clientRow.archived
|
||||||
|
? "Unarchive"
|
||||||
|
: "Archive"}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -472,7 +462,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{clientRow.blocked ? "Unblock" : "Block"}</span>
|
<span>
|
||||||
|
{clientRow.blocked
|
||||||
|
? "Unblock"
|
||||||
|
: "Block"}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!clientRow.userId && (
|
{!clientRow.userId && (
|
||||||
// Machine client - also show delete option
|
// Machine client - also show delete option
|
||||||
@@ -482,7 +476,9 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-red-500">Delete</span>
|
<span className="text-red-500">
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -570,32 +566,65 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
id: "active",
|
id: "active",
|
||||||
label: t("active") || "Active",
|
label: t("active"),
|
||||||
value: "active"
|
value: "active"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pending",
|
||||||
|
label: t("pendingApproval"),
|
||||||
|
value: "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "denied",
|
||||||
|
label: t("deniedApproval"),
|
||||||
|
value: "denied"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "archived",
|
id: "archived",
|
||||||
label: t("archived") || "Archived",
|
label: t("archived"),
|
||||||
value: "archived"
|
value: "archived"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "blocked",
|
id: "blocked",
|
||||||
label: t("blocked") || "Blocked",
|
label: t("blocked"),
|
||||||
value: "blocked"
|
value: "blocked"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
|
filterFn: (
|
||||||
|
row: ClientRow,
|
||||||
|
selectedValues: (string | number | boolean)[]
|
||||||
|
) => {
|
||||||
if (selectedValues.length === 0) return true;
|
if (selectedValues.length === 0) return true;
|
||||||
const rowArchived = row.archived || false;
|
const rowArchived = row.archived;
|
||||||
const rowBlocked = row.blocked || false;
|
const rowBlocked = row.blocked;
|
||||||
|
const approvalState = row.approvalState;
|
||||||
const isActive = !rowArchived && !rowBlocked;
|
const isActive = !rowArchived && !rowBlocked;
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive) return true;
|
if (selectedValues.includes("active") && isActive)
|
||||||
if (selectedValues.includes("archived") && rowArchived) return true;
|
return true;
|
||||||
if (selectedValues.includes("blocked") && rowBlocked) 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;
|
return false;
|
||||||
},
|
},
|
||||||
defaultValues: ["active"] // Default to showing active clients
|
defaultValues: ["active", "pending"] // Default to showing active clients
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
52
src/components/UserProfileCard.tsx
Normal file
52
src/components/UserProfileCard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-md">
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarFallback>{profileInitial}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{profileLabel}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground break-all">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onUseDifferentAccount && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="h-auto px-0 text-xs"
|
||||||
|
onClick={onUseDifferentAccount}
|
||||||
|
>
|
||||||
|
{useDifferentAccountText || "Use a different account"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -245,7 +245,7 @@ export default function VerifyEmailForm({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
>
|
>
|
||||||
Log in with another account
|
{t("verifyEmailLogInWithDifferentAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function SplashImage({ children }: SplashImageProps) {
|
|||||||
if (!env.branding.background_image_path) {
|
if (!env.branding.background_image_path) {
|
||||||
return false;
|
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) {
|
for (const prefix of pathsPrefixes) {
|
||||||
if (pathname.startsWith(prefix)) {
|
if (pathname.startsWith(prefix)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ export default function ValidateSessionTransferToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (doRedirect) {
|
if (doRedirect) {
|
||||||
// add redirect param to dashboardUrl if provided
|
if (props.redirect && props.redirect.startsWith("http")) {
|
||||||
const fullUrl = `${env.app.dashboardUrl}${props.redirect || ""}`;
|
router.push(props.redirect);
|
||||||
router.push(fullUrl);
|
} else {
|
||||||
|
// add redirect param to dashboardUrl if provided
|
||||||
|
const fullUrl = `${env.app.dashboardUrl}${props.redirect || ""}`;
|
||||||
|
router.push(fullUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const checkboxVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface CheckboxProps
|
interface CheckboxProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
extends
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||||
VariantProps<typeof checkboxVariants> {}
|
VariantProps<typeof checkboxVariants> {}
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
@@ -49,17 +50,18 @@ const Checkbox = React.forwardRef<
|
|||||||
));
|
));
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
interface CheckboxWithLabelProps
|
interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
|
||||||
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
|
typeof Checkbox
|
||||||
|
> {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckboxWithLabel = React.forwardRef<
|
const CheckboxWithLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof Checkbox>,
|
React.ComponentRef<typeof Checkbox>,
|
||||||
CheckboxWithLabelProps
|
CheckboxWithLabelProps
|
||||||
>(({ className, label, id, ...props }, ref) => {
|
>(({ className, label, id, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center space-x-2", className)}>
|
<div className={cn("flex items-center gap-x-2", className)}>
|
||||||
<Checkbox id={id} ref={ref} {...props} />
|
<Checkbox id={id} ref={ref} {...props} />
|
||||||
<label
|
<label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const DialogPortal = DialogPrimitive.Portal;
|
|||||||
const DialogClose = DialogPrimitive.Close;
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
@@ -30,7 +30,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
|
|||||||
51
src/hooks/useUserLookup.ts
Normal file
51
src/hooks/useUserLookup.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||||
|
|
||||||
|
type UseUserLookupResult = {
|
||||||
|
lookup: (identifier: string) => Promise<LookupUserResponse | null>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUserLookup(): UseUserLookupResult {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const lookup = async (
|
||||||
|
identifier: string
|
||||||
|
): Promise<LookupUserResponse | null> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post<
|
||||||
|
AxiosResponse<LookupUserResponse>
|
||||||
|
>("/auth/lookup-user", {
|
||||||
|
identifier: identifier.toLowerCase().trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.data) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError("Failed to lookup user");
|
||||||
|
return null;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"An error occurred during lookup";
|
||||||
|
setError(errorMessage);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { lookup, loading, error };
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
|
import type { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
import type {
|
||||||
|
GetResourceWhitelistResponse,
|
||||||
|
ListResourceNamesResponse
|
||||||
|
} from "@server/routers/resource";
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
@@ -7,20 +13,14 @@ import type {
|
|||||||
ListSiteResourceRolesResponse,
|
ListSiteResourceRolesResponse,
|
||||||
ListSiteResourceUsersResponse
|
ListSiteResourceUsersResponse
|
||||||
} from "@server/routers/siteResource";
|
} from "@server/routers/siteResource";
|
||||||
|
import type { ListTargetsResponse } from "@server/routers/target";
|
||||||
import type { ListUsersResponse } from "@server/routers/user";
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||||
import type { AxiosInstance, AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
|
||||||
import type {
|
|
||||||
GetResourceWhitelistResponse,
|
|
||||||
ListResourceNamesResponse
|
|
||||||
} from "@server/routers/resource";
|
|
||||||
import type { ListTargetsResponse } from "@server/routers/target";
|
|
||||||
import type { ListDomainsResponse } from "@server/routers/domain";
|
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -322,3 +322,47 @@ export const resourceQueries = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const approvalFiltersSchema = z.object({
|
||||||
|
approvalState: z
|
||||||
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
|
.optional()
|
||||||
|
.catch("all")
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ApprovalItem = {
|
||||||
|
approvalId: number;
|
||||||
|
orgId: string;
|
||||||
|
clientId: number | null;
|
||||||
|
decision: "pending" | "approved" | "denied";
|
||||||
|
type: "user_device";
|
||||||
|
user: {
|
||||||
|
name: string | null;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approvalQueries = {
|
||||||
|
listApprovals: (
|
||||||
|
orgId: string,
|
||||||
|
filters: z.infer<typeof approvalFiltersSchema>
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["APPROVALS", orgId, filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.approvalState) {
|
||||||
|
sp.set("approvalState", filters.approvalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<{ approvals: ApprovalItem[] }>
|
||||||
|
>(`/org/${orgId}/approvals?${sp.toString()}`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const defaultTheme = {
|
|||||||
accent: "oklch(0.967 0.001 286.375)",
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
destructive: "oklch(0.577 0.245 27.325)",
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
"destructive-foreground": "oklch(0.985 0 0)",
|
||||||
border: "oklch(0.92 0.004 286.32)",
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
input: "oklch(0.92 0.004 286.32)",
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
ring: "oklch(0.705 0.213 47.604)",
|
ring: "oklch(0.705 0.213 47.604)",
|
||||||
@@ -41,6 +42,7 @@ const defaultTheme = {
|
|||||||
accent: "oklch(0.274 0.006 286.033)",
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
"accent-foreground": "oklch(0.985 0 0)",
|
"accent-foreground": "oklch(0.985 0 0)",
|
||||||
destructive: "oklch(0.704 0.191 22.216)",
|
destructive: "oklch(0.704 0.191 22.216)",
|
||||||
|
"destructive-foreground": "oklch(0.985 0 0)",
|
||||||
border: "oklch(1 0 0 / 10%)",
|
border: "oklch(1 0 0 / 10%)",
|
||||||
input: "oklch(1 0 0 / 15%)",
|
input: "oklch(1 0 0 / 15%)",
|
||||||
ring: "oklch(0.646 0.222 41.116)",
|
ring: "oklch(0.646 0.222 41.116)",
|
||||||
|
|||||||
Reference in New Issue
Block a user