Created
June 14, 2025 14:10
-
-
Save theguynamedjake/79444c99acf0f11b8344fdc8f7fca092 to your computer and use it in GitHub Desktop.
UserDetailsDashboard.ps1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# UserDetailsDashboard.ps1 | |
$Global:IsListenerRunning = $true | |
# --- CONFIGURATION --- | |
# Settings are now managed by loading from settings.json at script start. | |
# Default settings are defined below if the file doesn't exist or is invalid. | |
# --- GLOBAL HELPER FUNCTIONS (Defined at the very top) --- | |
Function Format-IsoDateLocal($DateTimeValue) { | |
if ($DateTimeValue -and ($DateTimeValue -is [datetime] -or ($DateTimeValue -is [string] -and $DateTimeValue -ne ""))) { | |
try { | |
$dt = [datetime]$DateTimeValue | |
if ($dt -ne ([datetime]::MinValue) -and $dt -ne ([datetime]::MaxValue)) { return $dt.ToUniversalTime().ToString("o") } | |
} catch {} | |
} | |
return $null | |
} | |
Function Get-LicenseFriendlyNameMap { | |
#Write-Host "DEBUG MAP: Entering Get-LicenseFriendlyNameMap function." | |
$csvPath = Join-Path -Path $PSScriptRoot -ChildPath "licenses.csv" | |
#Write-Host "DEBUG MAP: Expected CSV Path is '$csvPath'" | |
$map = @{} | |
#Write-Host "DEBUG MAP: Initialized `$map. Type: $($map.GetType().FullName)" | |
if (Test-Path $csvPath) { | |
#Write-Host "DEBUG MAP: CSV file confirmed to exist at '$csvPath'." | |
try { | |
$csvData = Import-Csv -Path $csvPath -ErrorAction Stop | |
#Write-Host "DEBUG MAP: Import-Csv completed. Type of `$csvData is '$($csvData.GetType().FullName)'. Number of rows (if array): $($csvData.Count)" | |
if ($null -ne $csvData) { | |
$csvDataArray = @($csvData) | |
#Write-Host "DEBUG MAP: Processing $($csvDataArray.Count) rows from CSV." | |
foreach ($row in $csvDataArray) { | |
$productName = $null | |
$skuPartNumber = $null | |
$skuGuid = $null | |
if ($row.PSObject.Properties["Product_Display_Name"]) { | |
$productName = $row."Product_Display_Name" | |
} else { | |
Write-Host "DEBUG MAP WARNING: Row missing 'Product_Display_Name' property. Row content: $($row | Out-String)" | |
} | |
if ($row.PSObject.Properties["String_Id"]) { | |
$skuPartNumber = $row."String_Id" | |
} else { | |
Write-Host "DEBUG MAP WARNING: Row missing 'String_Id' property. Row content: $($row | Out-String)" | |
} | |
if ($row.PSObject.Properties["Guid"]) { | |
$skuGuid = $row."Guid" | |
} else { | |
Write-Host "DEBUG MAP WARNING: Row missing 'Guid' property. Row content: $($row | Out-String)" | |
} | |
if (-not [string]::IsNullOrWhiteSpace($productName)) { | |
if (-not [string]::IsNullOrWhiteSpace($skuPartNumber) -and -not $map.ContainsKey($skuPartNumber.ToUpper())) { | |
$map[$skuPartNumber.ToUpper()] = $productName | |
} | |
if (-not [string]::IsNullOrWhiteSpace($skuGuid) -and -not $map.ContainsKey($skuGuid.ToUpper())) { | |
$map[$skuGuid.ToUpper()] = $productName | |
} | |
} | |
} | |
} else { | |
#Write-Host "DEBUG MAP: `$csvData was null after Import-Csv. This means the CSV might be empty or could not be parsed." | |
} | |
# CHANGED Write-Output to Write-Host HERE: | |
#Write-Host "Successfully loaded $($map.Count) license friendly name mappings from CSV." | |
#Write-Host "DEBUG MAP: At end of try block. `$map Type: $($map.GetType().FullName), Count: $($map.Count)" | |
} catch { | |
Write-Warning "CRITICAL DEBUG: Error during CSV processing: $($_.Exception.Message). StackTrace: $($_.ScriptStackTrace)" | |
#Write-Host "DEBUG MAP: In catch block due to error. `$map Type: $($map.GetType().FullName), Count: $($map.Count)" | |
} | |
} else { | |
Write-Warning "CRITICAL DEBUG: License friendly name CSV not found at '$csvPath'. This is a primary issue." | |
#Write-Host "DEBUG MAP: CSV file not found. `$map Type: $($map.GetType().FullName), Count: $($map.Count)" | |
} | |
#Write-Host "DEBUG MAP: Returning from Get-LicenseFriendlyNameMap. Value of `$map being returned. Type: $($map.GetType().FullName). Count: $($map.Count)" | |
return $map # This should now be the only thing "returned" via the pipeline | |
} | |
Function Get-FriendlyLicenseName($SkuIdentifier) { | |
if (-not $SkuIdentifier) { return "[Unknown SKU]" } | |
if ($Global:LicenseFriendlyNameMap -isnot [hashtable]) { | |
#Write-Warning "LicenseFriendlyNameMap is not a valid hashtable. Re-initializing." | |
$Global:LicenseFriendlyNameMap = Get-LicenseFriendlyNameMap | |
if ($Global:LicenseFriendlyNameMap -isnot [hashtable]) { | |
Write-Warning "Failed to re-initialize LicenseFriendlyNameMap. Returning original SKU." | |
return $SkuIdentifier.ToString() | |
} | |
} | |
$cleanSku = $SkuIdentifier.ToString().Split(':')[-1].ToUpper() | |
if ($Global:LicenseFriendlyNameMap.ContainsKey($cleanSku)) { | |
return $Global:LicenseFriendlyNameMap[$cleanSku] | |
} | |
return $SkuIdentifier.ToString() | |
} | |
Function Get-ComprehensiveUserDetails { | |
[CmdletBinding()] | |
[OutputType([Hashtable])] | |
param( | |
[Parameter(Mandatory)] | |
[string]$UserUpn | |
) | |
Write-Host ("Fetching user details for {0}" -f $UserUpn) -ForegroundColor Cyan | |
$userData = @{} | |
try { | |
$coreUserDetails = $null | |
$detailedProps = $null | |
$exchangeMailbox = $null | |
# Initialize all expected keys with default/empty values | |
$userData = @{ | |
UserPrincipalName = $UserUpn; Id = ""; DisplayName = ""; FirstName = ""; LastName = ""; Mail = ""; Notes = ""; | |
JobTitle = ""; Department = ""; ManagerDisplayName = ""; CompanyName = ""; UserType = ""; AccountEnabled = $false; | |
IsLicensed = $false; Licenses = @(); UsageLocation = ""; OnPremisesSyncEnabled = $false; | |
PasswordLastChangedDateTime = $null; LastSignInDateTime = $null; CreatedDateTime = $null; | |
PhysicalDeliveryOfficeName = ""; StreetAddress = ""; City = ""; State = ""; PostalCode = ""; Country = ""; | |
MobilePhone = ""; BusinessPhones = ""; FaxNumber = ""; MailNickname = ""; AboutMe = ""; | |
MfaMethods = @(); HasDirectReports = $false; DirectReportsList = @(); UserDevices = @(); | |
GroupMemberships = @(); | |
RecipientTypeDetails = ""; MailboxQuota = ""; ForwardingSmtpAddress = ""; DeliverToMailboxAndForward = $null; HiddenFromAddressLists = $null; | |
ArchiveStatus = ""; ProxyAddresses = @(); FullAccessDelegates = @(); SendAsDelegates = @(); SendOnBehalfDelegates = @(); | |
OofStatus = ""; OofExternalAudience = ""; OofScheduled = ""; OofStartTime = $null; OofEndTime = $null; OofInternalReply = ""; OofExternalReply = ""; | |
TeamsPhoneNumber = ""; TeamsPhoneType = ""; TeamsEnterpriseVoice = ""; TeamsCallingPolicy = ""; TeamsUpgradePolicy = ""; TenantDialPlan = ""; OnlineVoiceRoutingPolicy = ""; | |
TeamsError = ""; ExchangeError = ""; GraphError = "" | |
} | |
$userData.TenantName = $Global:Script:FetchedTenantName | |
if (-not $Global:ConnectionState.Graph) { | |
$userData.GraphError = "Graph service not connected." | |
return $userData | |
} | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching Graph core user object for UPN: {0}" -f $UserUpn) | |
# Select a minimal set of always-needed properties for the initial Get-MgUser call | |
$coreUserObject = Get-MgUser -UserId $UserUpn -Property Id, UserPrincipalName, DisplayName, GivenName, Surname, Mail, MailNickname, AccountEnabled, AboutMe -ErrorAction Stop | |
if (-not $coreUserObject) { | |
$userData.GraphError = "User with UPN '$UserUpn' not found via Get-MgUser." | |
throw $userData.GraphError | |
} | |
$userId = $coreUserObject.Id | |
$userData.UserPrincipalName = $coreUserObject.UserPrincipalName | |
$userData.Id = $userId | |
$userData.DisplayName = $coreUserObject.DisplayName | |
$userData.FirstName = $coreUserObject.GivenName | |
$userData.LastName = $coreUserObject.Surname | |
$userData.Mail = $coreUserObject.Mail | |
$userData.MailNickname = $coreUserObject.MailNickname | |
$userData.AccountEnabled = $coreUserObject.AccountEnabled | |
$userData.MailDisplayName = $coreUserObject.DisplayName | |
$userData.Notes = if([string]::IsNullOrEmpty($coreUserObject.AboutMe)) { "" } else { $coreUserObject.AboutMe } | |
# Define properties for the second, more detailed Get-MgUser call | |
$propertiesToFetch = @( | |
"JobTitle", "Department", "CompanyName", "UserType", | |
"UsageLocation", "OnPremisesSyncEnabled", "CreatedDateTime", "faxNumber", | |
"OfficeLocation", "StreetAddress", "City", "State", "PostalCode", "Country", | |
"MobilePhone", "BusinessPhones", | |
"SignInActivity", "PasswordLastChangedDateTime", "AssignedLicenses" | |
) | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching additional Graph properties for User ID: {0}" -f $userId) | |
$detailedProps = Get-MgUser -UserId $userId -Property $propertiesToFetch -ErrorAction SilentlyContinue | |
if (-not $detailedProps) { | |
Write-Warning ("Could not fetch detailed properties for {0} using ID. Some data might be from the core object only." -f $userId) | |
$detailedProps = $coreUserObject # Fallback to core object if detailed fetch fails, might miss some props | |
} | |
# Populate userData with detailed properties, checking for existence | |
$userData.JobTitle = if ([string]::IsNullOrEmpty($detailedProps.JobTitle)) { "" } else { $detailedProps.JobTitle } | |
$userData.Department = if ([string]::IsNullOrEmpty($detailedProps.Department)) { "" } else { $detailedProps.Department } | |
$userData.CompanyName = if ([string]::IsNullOrEmpty($detailedProps.CompanyName)) { "" } else { $detailedProps.CompanyName } | |
$userData.UserType = if ([string]::IsNullOrEmpty($detailedProps.UserType)) { "" } else { $detailedProps.UserType } | |
$userData.UsageLocation = if ([string]::IsNullOrEmpty($detailedProps.UsageLocation)) { "" } else { $detailedProps.UsageLocation } | |
$userData.OnPremisesSyncEnabled = if ($null -ne $detailedProps.OnPremisesSyncEnabled) { [bool]$detailedProps.OnPremisesSyncEnabled } else { $false } | |
$userData.PasswordLastChangedDateTime = Format-IsoDateLocal($detailedProps.PasswordLastChangedDateTime) | |
$userData.LastSignInDateTime = if ($detailedProps.SignInActivity) { Format-IsoDateLocal($detailedProps.SignInActivity.LastSignInDateTime) } else { $null } | |
$userData.CreatedDateTime = Format-IsoDateLocal($detailedProps.CreatedDateTime) | |
$userData.PhysicalDeliveryOfficeName = if ([string]::IsNullOrEmpty($detailedProps.OfficeLocation)) { "" } else { $detailedProps.OfficeLocation } | |
$userData.StreetAddress = if ([string]::IsNullOrEmpty($detailedProps.StreetAddress)) { "" } else { $detailedProps.StreetAddress } | |
$userData.City = if ([string]::IsNullOrEmpty($detailedProps.City)) { "" } else { $detailedProps.City } | |
$userData.State = if ([string]::IsNullOrEmpty($detailedProps.State)) { "" } else { $detailedProps.State } | |
$userData.PostalCode = if ([string]::IsNullOrEmpty($detailedProps.PostalCode)) { "" } else { $detailedProps.PostalCode } | |
$userData.Country = if ([string]::IsNullOrEmpty($detailedProps.Country)) { "" } else { $detailedProps.Country } | |
$userData.MobilePhone = if ([string]::IsNullOrEmpty($detailedProps.MobilePhone)) { "" } else { $detailedProps.MobilePhone } | |
$userData.BusinessPhones = if ($detailedProps.BusinessPhones) { $detailedProps.BusinessPhones -join '; ' } else { '' } | |
$userData.FaxNumber = if ([string]::IsNullOrEmpty($detailedProps.FaxNumber)) { "" } else { $detailedProps.FaxNumber } | |
# Manager | |
$managerDisplayName = "" | |
try { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching manager for {0}" -f $userId) | |
$managerDirectoryObject = Get-MgUserManager -UserId $userId -ErrorAction SilentlyContinue # Fetches as DirectoryObject | |
if ($managerDirectoryObject -and $managerDirectoryObject.Id) { | |
# Get-MgUser to fetch DisplayName for the manager | |
$managerUser = Get-MgUser -UserId $managerDirectoryObject.Id -Property DisplayName -ErrorAction SilentlyContinue | |
if ($managerUser -and $managerUser.DisplayName) { $managerDisplayName = $managerUser.DisplayName } | |
elseif ($managerDirectoryObject.AdditionalProperties.'userPrincipalName') { $managerDisplayName = $managerDirectoryObject.AdditionalProperties.'userPrincipalName' } # Fallback | |
elseif ($managerDirectoryObject.DisplayName){ $managerDisplayName = $managerDirectoryObject.DisplayName } # Fallback | |
} | |
} catch { Write-Warning ("Could not fetch manager for {0}: {1}" -f $UserUpn, $_.Exception.Message); $userData.GraphError += " Manager fetch failed." } | |
$userData.ManagerDisplayName = $managerDisplayName | |
# Licenses | |
$licenseDetails = @() | |
if ($detailedProps.AssignedLicenses) { | |
#Write-Host "DEBUG LICENSE: Found $($detailedProps.AssignedLicenses.Count) assigned licenses. Processing..." | |
foreach ($license in $detailedProps.AssignedLicenses) { | |
if ($license.SkuId) { | |
$currentSkuIdStr = $license.SkuId.ToString() | |
#Write-Host "DEBUG LICENSE: --- Processing SkuId (GUID): $currentSkuIdStr ---" | |
# Initial attempt to get friendly name using the GUID | |
$friendlyName = Get-FriendlyLicenseName -SkuIdentifier $currentSkuIdStr | |
#Write-Host "DEBUG LICENSE: Step 1 - Result from Get-FriendlyLicenseName(GUID '$currentSkuIdStr'): '$friendlyName'" | |
# Check if the result from Step 1 is just the GUID itself (meaning no mapping for the GUID was found) | |
$isGuidRegex = "^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" | |
if (($friendlyName -match $isGuidRegex) -and ($friendlyName.Equals($currentSkuIdStr, [System.StringComparison]::OrdinalIgnoreCase))) { | |
#Write-Host "DEBUG LICENSE: Step 2 - Initial name is the GUID. Attempting SkuPartNumber fallback." | |
try { | |
$skuObject = Get-MgSubscribedSku -SubscribedSkuId $currentSkuIdStr -ErrorAction SilentlyContinue | |
if ($skuObject -and (-not [string]::IsNullOrWhiteSpace($skuObject.SkuPartNumber))) { | |
$skuPartNumber = $skuObject.SkuPartNumber | |
#Write-Host "DEBUG LICENSE: Step 3 - Found SkuPartNumber: '$skuPartNumber' for GUID '$currentSkuIdStr'" | |
# Attempt to get a friendly name for the SkuPartNumber | |
$friendlyNameFromPartNumber = Get-FriendlyLicenseName -SkuIdentifier $skuPartNumber | |
#Write-Host "DEBUG LICENSE: Step 4 - Result from Get-FriendlyLicenseName(SkuPartNumber '$skuPartNumber'): '$friendlyNameFromPartNumber'" | |
# Logic to decide final name: | |
# If SkuPartNumber yielded a true friendly name (i.e., not the SkuPartNumber itself), use it. | |
if ($friendlyNameFromPartNumber -ne $skuPartNumber) { | |
$friendlyName = $friendlyNameFromPartNumber | |
#Write-Host "DEBUG LICENSE: Step 5a - Using mapped friendly name from SkuPartNumber: '$friendlyName'" | |
# Else (if SkuPartNumber mapping returned SkuPartNumber itself), use SkuPartNumber as the fallback. | |
} else { | |
$friendlyName = $skuPartNumber | |
#Write-Host "DEBUG LICENSE: Step 5b - Using SkuPartNumber itself as fallback: '$friendlyName'" | |
} | |
} else { | |
#Write-Host "DEBUG LICENSE: Step 3 - Get-MgSubscribedSku did NOT return an object with a SkuPartNumber for GUID '$currentSkuIdStr'." | |
if ($null -eq $skuObject) { #Write-Host "DEBUG LICENSE: (skuObject was null)" | |
} | |
elseif ([string]::IsNullOrWhiteSpace($skuObject.SkuPartNumber)) {#Write-Host "DEBUG LICENSE: (skuObject.SkuPartNumber was null or empty)" | |
} | |
# If no SkuPartNumber, $friendlyName remains the original GUID. | |
#Write-Host "DEBUG LICENSE: Sticking with GUID as no SkuPartNumber found: '$friendlyName'" | |
} | |
} catch { | |
#Write-Warning "DEBUG LICENSE: Error during Get-MgSubscribedSku for $currentSkuIdStr : $($_.Exception.Message)" | |
#Write-Host "DEBUG LICENSE: Sticking with GUID due to error: '$friendlyName'" | |
} | |
} else { | |
#Write-Host "DEBUG LICENSE: Step 2 - Initial name '$friendlyName' is NOT the GUID (it's already a friendly name from map). Using it directly." | |
} | |
$licenseDetails += $friendlyName | |
#Write-Host "DEBUG LICENSE: Final friendlyName added for ${currentSkuIdStr}: '${friendlyName}'" | |
#Write-Host "-----------------------------------------------------" | |
} else { | |
#Write-Host "DEBUG LICENSE: License object found but SkuId was null or empty." | |
} | |
} | |
} else { | |
#Write-Host "DEBUG LICENSE: No AssignedLicenses found for the user." | |
} | |
$userData.IsLicensed = if ($licenseDetails.Count -gt 0) { $true } else { $false } | |
$userData.Licenses = @($licenseDetails | Sort-Object) | |
# MFA Methods | |
$mfaMethodsDescribed = @() | |
try { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching MFA methods for {0}" -f $userId) | |
# Ensure UserAuthenticationMethod.Read.All or UserAuthenticationMethod.ReadWrite.All is consented | |
$authMethods = Get-MgUserAuthenticationMethod -UserId $userId -ErrorAction Stop # Use Stop to catch permission errors | |
if ($authMethods) { | |
foreach ($method in $authMethods) { | |
$odataType = $null; if ($method.AdditionalProperties -and $method.AdditionalProperties.ContainsKey('@odata.type')) { $odataType = $method.AdditionalProperties.'@odata.type' } | |
$methodTypeFriendly = if($odataType){ $odataType.ToString().Split('.')[-1] } else { "UnknownMethod" } | |
$methodDetail = ""; $detailSuffix = "" | |
switch ($methodTypeFriendly) { | |
"phoneAuthenticationMethod" { if ($method.PhoneNumber) { $detailSuffix = ": " + $method.PhoneNumber }; $phoneTypeString = if ($method.phoneType) {$method.phoneType.ToString()} else {"N/A"}; $methodDetail = "Phone ($phoneTypeString)" + $detailSuffix } | |
"microsoftAuthenticatorAuthenticationMethod" { if ($method.DisplayName) { $detailSuffix = ": " + $method.DisplayName }; $methodDetail = "Authenticator App" + $detailSuffix } | |
# Add more cases for other methods like Fido2, Email, Passwordless Phone Sign-in, etc. | |
default { $methodDetail = $methodTypeFriendly } | |
} | |
$mfaMethodsDescribed += $methodDetail | |
} | |
} | |
} catch { $mfaMethodsDescribed = @(); $userData.GraphError += " MFA fetch failed. Ensure correct permissions (UserAuthenticationMethod.Read(Write).All)." } # Catch block for Get-MgUserAuthenticationMethod | |
$userData.MfaMethods = $mfaMethodsDescribed | |
# Registered Devices | |
$userDevicesList = @() | |
try { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching registered devices for {0}" -f $userId) | |
$registeredDevices = Get-MgUserRegisteredDevice -UserId $userId -All -ErrorAction SilentlyContinue # Get all devices | |
if ($null -ne $registeredDevices -and $registeredDevices.GetType().Name -ne 'Array') { $registeredDevices = @($registeredDevices) } # Ensure it's an array if single item returned | |
if ($registeredDevices) { | |
foreach ($device in $registeredDevices) { | |
# Prefer DisplayName and OperatingSystem from direct properties if available | |
$deviceName = if ($device.PSObject.Properties['DisplayName'] -and -not([string]::IsNullOrEmpty($device.DisplayName))) { $device.DisplayName } elseif ($device.AdditionalProperties.ContainsKey('displayName') -and -not([string]::IsNullOrEmpty($device.AdditionalProperties['displayName']))) { $device.AdditionalProperties['displayName'] } else { "[Name N/A]" } | |
$deviceOS = if ($device.PSObject.Properties['OperatingSystem'] -and -not([string]::IsNullOrEmpty($device.OperatingSystem))) { $device.OperatingSystem } elseif ($device.AdditionalProperties.ContainsKey('operatingSystem') -and -not([string]::IsNullOrEmpty($device.AdditionalProperties['operatingSystem']))) { $device.AdditionalProperties['operatingSystem'] } else { "[OS N/A]" } | |
$userDevicesList += "$($deviceName) ($($deviceOS))" | |
} | |
if ($userDevicesList.Count -eq 0 -and $registeredDevices.Count -gt 0) { $userDevicesList = @("No devices with name/OS information found.") } # If devices exist but no info extracted | |
elseif ($userDevicesList.Count -eq 0) { $userDevicesList = @("No devices registered for this user.") } # If no devices at all | |
} else { $userDevicesList = @("No devices registered for this user.") } # If Get-MgUserRegisteredDevice returned null/empty | |
} catch { $userDevicesList = @(); $userData.GraphError += " Device fetch failed." } # Catch errors from Get-MgUserRegisteredDevice | |
$userData.UserDevices = $userDevicesList | |
# Direct Reports | |
$directReportsData = @() | |
$hasDirectReports = $false | |
try { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching direct reports for {0}" -f $userId) | |
$directReportsResult = Get-MgUserDirectReport -UserId $userId -All -ErrorAction SilentlyContinue # Get all reports | |
if ($directReportsResult) { | |
$hasDirectReports = $true | |
if ($directReportsResult -isnot [array]) { $directReportsResult = @($directReportsResult) } # Ensure array | |
foreach ($report in $directReportsResult) { | |
# Attempt to get DisplayName, fallback to ID if necessary | |
$reportDisplayName = $null | |
if ($report.AdditionalProperties.ContainsKey('displayName') -and -not([string]::IsNullOrEmpty($report.AdditionalProperties.displayName))) { $reportDisplayName = $report.AdditionalProperties.displayName } | |
elseif ($report.PSObject.Properties['DisplayName'] -and -not([string]::IsNullOrEmpty($report.DisplayName))) { $reportDisplayName = $report.DisplayName } | |
elseif ($report.Id) { try { $reportUser = Get-MgUser -UserId $report.Id -Property DisplayName -ErrorAction SilentlyContinue; if ($reportUser) { $reportDisplayName = $reportUser.DisplayName } } catch {} } | |
$directReportsData += ($reportDisplayName | Out-String).Trim() # Ensure it's a string | |
} | |
if ($directReportsData.Count -eq 0 -and $directReportsResult.Count -gt 0) { $directReportsData = @("[Could not retrieve report names]") } | |
elseif ($directReportsData.Count -eq 0) { $directReportsData = @("No direct reports found.") } | |
} else { $hasDirectReports = $false; $directReportsData = @("No direct reports found.") } | |
} catch { $directReportsData = @(); $hasDirectReports = $false; $userData.GraphError += " Direct reports fetch failed." } | |
$userData.HasDirectReports = $hasDirectReports | |
$userData.DirectReportsList = $directReportsData | |
# Group Memberships | |
$groupDataForJs = @() | |
try { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching group memberships for {0}" -f $userId) | |
$memberOf = Get-MgUserMemberOf -UserId $userId -All -ErrorAction SilentlyContinue # Get all memberships | |
if ($memberOf) { | |
if ($memberOf -isnot [array]) { $memberOf = @($memberOf) } # Ensure array | |
foreach ($groupObject in $memberOf) { | |
# We only care about actual groups, not directory roles, etc. | |
if ($groupObject.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') { | |
try { | |
# Fetch specific details for each group | |
$groupDetail = Get-MgGroup -GroupId $groupObject.Id -Property DisplayName, SecurityEnabled, MailEnabled, GroupTypes -ErrorAction SilentlyContinue | |
if ($groupDetail) { | |
$groupDataForJs += @{ | |
DisplayName = $groupDetail.DisplayName | |
SecurityEnabled = $groupDetail.SecurityEnabled | |
MailEnabled = $groupDetail.MailEnabled | |
GroupTypes = @($groupDetail.GroupTypes) | |
} | |
} | |
} catch { Write-Warning ("Error fetching details for group ID {0}: {1}" -f $groupObject.Id, $_.Exception.Message) } | |
} | |
} | |
} | |
if ($groupDataForJs.Count -eq 0) { $groupDataForJs = @() } # Ensure it's an empty array if no groups found, not $null | |
} catch { $groupDataForJs = @(); $userData.GraphError += " Group membership fetch failed." } | |
$userData.GroupMemberships = $groupDataForJs # This will be an array of hashtables | |
$userData.TenantName = $Global:Script:FetchedTenantName # Re-affirm tenant name after all Graph calls | |
# Exchange Online Details | |
if ($Global:ConnectionState.Exchange) { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching Exchange details for {0}" -f $UserUpn) | |
try { | |
$exchangeMailbox = Get-EXOMailbox -Identity $UserUpn -Properties "ForwardingSmtpAddress", "DeliverToMailboxAndForward" -ErrorAction Stop | |
if($exchangeMailbox){ | |
$userData.RecipientTypeDetails = if($exchangeMailbox.RecipientTypeDetails) {$exchangeMailbox.RecipientTypeDetails.ToString()} else {""} | |
$rawFwdAddress = if ($exchangeMailbox.ForwardingSmtpAddress) { $exchangeMailbox.ForwardingSmtpAddress.ToString() } else { "" } | |
# Use -replace to remove the "smtp:" prefix (case-insensitive) | |
$userData.ForwardingSmtpAddress = if (-not [string]::IsNullOrWhiteSpace($rawFwdAddress)) { $rawFwdAddress -replace '^smtp:', '' } else { "" } | |
$userData.DeliverToMailboxAndForward = if($null -ne $exchangeMailbox.DeliverToMailboxAndForward){[bool]$exchangeMailbox.DeliverToMailboxAndForward} else {$null} | |
# HiddenFromAddressListsEnabled - needs Get-Mailbox as Get-EXOMailbox doesn't always show it | |
$hiddenFromGALValue = $null | |
try { | |
$mbxForGAL = Get-Mailbox -Identity $UserUpn | Select-Object HiddenFromAddressListsEnabled -ErrorAction SilentlyContinue | |
if ($null -ne $mbxForGAL) { | |
$hiddenFromGALValue = [bool]$mbxForGAL.HiddenFromAddressListsEnabled | |
} else { | |
# Fallback if Get-Mailbox fails, try to get from Get-EXOMailbox object if property exists | |
if ($exchangeMailbox.PSObject.Properties.Name -contains 'HiddenFromAddressListsEnabled') { | |
$hiddenFromGALValue = [bool]$exchangeMailbox.HiddenFromAddressListsEnabled | |
} | |
} | |
} catch { | |
# If Get-Mailbox fails, try to get from Get-EXOMailbox object if property exists | |
if ($exchangeMailbox.PSObject.Properties.Name -contains 'HiddenFromAddressListsEnabled') { | |
$hiddenFromGALValue = [bool]$exchangeMailbox.HiddenFromAddressListsEnabled | |
} | |
# Write-Warning ("Could not get HiddenFromAddressListsEnabled via Get-Mailbox for {0}: {1}" -f $UserUpn, $_.Exception.Message) | |
} | |
$userData.HiddenFromAddressLists = $hiddenFromGALValue | |
# Archive Status | |
$archiveStatusString = "" | |
if ($exchangeMailbox.ArchiveGuid -ne $null -and $exchangeMailbox.ArchiveGuid -ne [System.Guid]::Empty) { | |
$archiveStatusString = "Enabled" | |
if ($exchangeMailbox.ArchiveState -and $exchangeMailbox.ArchiveState.ToString() -ne "None" -and $exchangeMailbox.ArchiveState.ToString().Trim() -ne "") { | |
$archiveStatusString += (" (" + $exchangeMailbox.ArchiveState.ToString() + ")") | |
} | |
} else { $archiveStatusString = "Disabled"} | |
$userData.ArchiveStatus = $archiveStatusString | |
$userData.ProxyAddresses = @($exchangeMailbox.EmailAddresses | Where-Object {$_ -like "SMTP:*" -or $_ -like "smtp:*"}) | |
try { | |
# Attempt to get Notes from Exchange first if available, as Graph's AboutMe might be different | |
$exchangeUserForNotes = Get-User -Identity $UserUpn -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Notes | |
if ($exchangeUserForNotes) { $userData.Notes = $exchangeUserForNotes } | |
elseif ([string]::IsNullOrEmpty($userData.Notes)) { # If Graph AboutMe was empty, and Get-User also fails or returns empty | |
$userData.Notes = "" # Ensure it's an empty string not null for consistency | |
} | |
} catch { Write-Warning ("Could not fetch Notes via Get-User for {0}: {1}" -f $UserUpn, $_.Exception.Message)} | |
# Mailbox Quota | |
$userData.MailboxQuota = "[Not Fetched]" # Default | |
try { | |
$exoMailboxStats = Get-EXOMailboxStatistics -Identity $UserUpn -ErrorAction SilentlyContinue | |
$usedSpaceString = "[N/A]" # Default if not calculable | |
$totalSpaceString = "[N/A]" # Default if not calculable | |
if ($exoMailboxStats -and $exoMailboxStats.TotalItemSize -and $exoMailboxStats.TotalItemSize.Value) { | |
$usedBytes = $exoMailboxStats.TotalItemSize.Value.ToBytes() # Convert to bytes | |
if ($usedBytes -ge 1GB) { $usedSpaceString = "{0:N2} GB" -f ($usedBytes / 1GB) } | |
elseif ($usedBytes -ge 1MB) { $usedSpaceString = "{0:N0} MB" -f ($usedBytes / 1MB) } | |
elseif ($usedBytes -gt 0) { $usedSpaceString = "{0:N0} KB" -f ($usedBytes / 1KB) } | |
else { $usedSpaceString = "0 KB"} # Handle 0 bytes explicitly | |
} else { Write-Warning ("Could not determine used space for {0} from Get-EXOMailboxStatistics." -f $UserUpn) } | |
# Get total quota from Get-Mailbox as Get-EXOMailboxStatistics doesn't show it easily | |
$mailboxDetailsForQuota = Get-Mailbox -Identity $UserUpn -ErrorAction SilentlyContinue | |
if ($mailboxDetailsForQuota -and $null -ne $mailboxDetailsForQuota.ProhibitSendReceiveQuota) { | |
$quota = $mailboxDetailsForQuota.ProhibitSendReceiveQuota | |
if ($quota.IsUnlimited) { | |
$totalSpaceString = "Unlimited" | |
} elseif ($null -ne $quota.Value) { # Check if Value itself is not null | |
$quotaStringRepresentation = $quota.ToString() | |
if ($quotaStringRepresentation -match "^([\d\.]+\s*(GB|MB|KB))") { # Try to parse the friendly string first | |
$totalSpaceString = $Matches[1] | |
} else { # Fallback to byte conversion if regex fails | |
try { | |
$limitInGB = $quota.Value.ToGB(); $limitInMB = $quota.Value.ToMB(); $limitInKB = $quota.Value.ToKB() | |
if ($limitInGB -ge 1) { $totalSpaceString = "{0:N0} GB" -f $limitInGB } | |
elseif ($limitInMB -ge 1) { $totalSpaceString = "{0:N0} MB" -f $limitInMB } | |
elseif ($limitInKB -ge 0) { $totalSpaceString = "{0:N0} KB" -f $limitInKB } # Allow 0 KB | |
else { Write-Warning ("ProhibitSendReceiveQuota for {0}: Byte conversion resulted in zero/less. Raw: {1}" -f $UserUpn, $quota.Value.ToString()); $totalSpaceString = "[Calc Error]" } | |
} catch { Write-Warning ("ProhibitSendReceiveQuota for {0}: Error converting value. Raw: {1}. Error: {2}" -f $UserUpn, $quota.Value.ToString(), $_.Exception.Message); $totalSpaceString = "[Convert Error]" } | |
} | |
} else { | |
#Write-Host ("ProhibitSendReceiveQuota.Value for {0} is null, though IsUnlimited is false. Using string representation." -f $UserUpn); | |
$quotaString = $quota.ToString(); if ($quotaString -match "^([\d\.]+\s*(GB|MB|KB))") { $totalSpaceString = $Matches[1] } else { $totalSpaceString = "[Default/Unknown Limit]" }} | |
} else { Write-Warning ("ProhibitSendReceiveQuota property not found or null for {0} using Get-Mailbox." -f $UserUpn); $totalSpaceString = "[Prop Missing]" } | |
# Construct the final quota string | |
if ($usedSpaceString -ne "[N/A]" -and $totalSpaceString -ne "[N/A]" -and $totalSpaceString -notlike "[*]*") { # Both valid and total is not an error/placeholder | |
$userData.MailboxQuota = "$usedSpaceString / $totalSpaceString" | |
} | |
elseif ($usedSpaceString -ne "[N/A]") { # Only used space is valid | |
$userData.MailboxQuota = "$usedSpaceString (Limit: $totalSpaceString)" | |
} | |
else { # Only total space is valid (or both are placeholders) | |
$userData.MailboxQuota = "Limit: $totalSpaceString" | |
} | |
} catch { Write-Warning ("Error processing Mailbox Quota for {0}: {1}" -f $UserUpn, $_.Exception.Message); $userData.MailboxQuota = "[Error Quota]"; } | |
# Delegations | |
try { $userData.FullAccessDelegates = @(Get-EXOMailboxPermission -Identity $UserUpn -ErrorAction SilentlyContinue | Where-Object {$_.AccessRights -match "FullAccess" -and $_.IsInherited -eq $false -and $_.User -ne "NT AUTHORITY\SELF"} | ForEach-Object {$_.User.ToString()}) } catch { $userData.FullAccessDelegates = @() } | |
try { $userData.SendAsDelegates = @(Get-RecipientPermission -Identity $UserUpn -ErrorAction SilentlyContinue | Where-Object {$_.Trustee -ne "NT AUTHORITY\SELF" -and $_.IsInherited -eq $false} | ForEach-Object {$_.Trustee.ToString()}) } catch { $userData.SendAsDelegates = @() } | |
try { | |
$sendOnBehalfToObjects = $exchangeMailbox.GrantSendOnBehalfTo | |
$sendOnBehalfNames = @() | |
if ($null -ne $sendOnBehalfToObjects) { | |
if ($sendOnBehalfToObjects -isnot [array]) { $sendOnBehalfToObjects = @($sendOnBehalfToObjects) } # Ensure array | |
foreach ($delegateItem in $sendOnBehalfToObjects) { | |
if ($delegateItem -and $delegateItem.PSObject.Properties['Name']) { # Check if it's an object with a Name property | |
$sendOnBehalfNames += $delegateItem.Name | |
} elseif ($delegateItem) { # Fallback for simple string representations | |
$sendOnBehalfNames += ($delegateItem | Out-String).Trim() | |
} | |
} | |
} | |
$userData.SendOnBehalfDelegates = $sendOnBehalfNames | |
} catch { $userData.SendOnBehalfDelegates = @(); Write-Warning ("Error fetching SendOnBehalf: " + $_.Exception.Message) } | |
# OOF Settings | |
try { | |
$oofSettings = Get-MailboxAutoReplyConfiguration -Identity $UserUpn -ErrorAction SilentlyContinue | |
if ($oofSettings) { | |
$userData.OofStatus = if($oofSettings.AutoReplyState) {$oofSettings.AutoReplyState.ToString()} else {""} | |
$userData.OofExternalAudience = if($oofSettings.ExternalAudience) {$oofSettings.ExternalAudience.ToString()} else {""} | |
$userData.OofScheduled = if($oofSettings.AutoReplyState -eq "Scheduled" -and $oofSettings.StartTime -and $oofSettings.EndTime){"Yes (" + (Get-Date $oofSettings.StartTime).ToString("g") + " - " + (Get-Date $oofSettings.EndTime).ToString("g") + ")"} else {"No"} | |
$userData.OofStartTime = if($oofSettings.StartTime){ Format-IsoDateLocal($oofSettings.StartTime) }else{$null} | |
$userData.OofEndTime = if($oofSettings.EndTime){ Format-IsoDateLocal($oofSettings.EndTime) }else{$null} | |
$userData.OofInternalReply = $oofSettings.InternalMessage | |
$userData.OofExternalReply = $oofSettings.ExternalMessage | |
} else { | |
# Ensure OOF fields are blank if no settings found | |
$userData.OofStatus = ""; $userData.OofExternalAudience = ""; $userData.OofScheduled = ""; $userData.OofStartTime = $null; $userData.OofEndTime = $null; $userData.OofInternalReply = ""; $userData.OofExternalReply = ""; | |
} | |
} catch { Write-Warning ("Error fetching OOF for {0}: {1}" -f $UserUpn, $_.Exception.Message); $userData.OofStatus = ""; $userData.OofExternalAudience = ""; $userData.OofScheduled = ""; $userData.OofInternalReply = ""; $userData.OofExternalReply = ""; } | |
} else { Write-Warning "Get-EXOMailbox failed for $UserUpn (Mailbox likely deleted or no license)." ; $userData.ExchangeError = "Mailbox not found or no license."} # End if($exchangeMailbox) | |
} catch { Write-Warning ("Overall error fetching Exchange details for {0}: {1}" -f $UserUpn, $_.Exception.Message); $userData.ExchangeError = "Exchange details failed." } | |
} | |
# Microsoft Teams Details | |
if ($Global:ConnectionState.Teams -and $coreUserObject.UserPrincipalName) { | |
#Write-Host ("DEBUG PS (Get-ComprehensiveUserDetails): Fetching Teams details for {0}" -f $UserUpn) | |
try { | |
$csUser = Get-CsOnlineUser -Identity $coreUserObject.UserPrincipalName -ErrorAction Stop | |
if ($csUser) { | |
$teamsPhoneNumber = $csUser.LineURI; if ($teamsPhoneNumber -and $teamsPhoneNumber.StartsWith("tel:")) { $teamsPhoneNumber = $teamsPhoneNumber.Substring(4) } | |
$userData.TeamsPhoneNumber = if ([string]::IsNullOrEmpty($teamsPhoneNumber)) { "" } else { $teamsPhoneNumber } | |
$userData.TeamsEnterpriseVoice = if ($null -ne $csUser.EnterpriseVoiceEnabled) { [bool]$csUser.EnterpriseVoiceEnabled } else { "" } | |
# TeamsCallingPolicy can be a string or an object with Identity/Name | |
if ($csUser.TeamsCallingPolicy -is [string]) { $userData.TeamsCallingPolicy = $csUser.TeamsCallingPolicy } | |
elseif ($csUser.TeamsCallingPolicy -and $csUser.TeamsCallingPolicy.Identity) { $userData.TeamsCallingPolicy = $csUser.TeamsCallingPolicy.Identity } | |
elseif ($csUser.TeamsCallingPolicy -and $csUser.TeamsCallingPolicy.Name) { $userData.TeamsCallingPolicy = $csUser.TeamsCallingPolicy.Name } | |
else { $userData.TeamsCallingPolicy = ($csUser.TeamsCallingPolicy | Out-String).Trim() } # Fallback | |
if([string]::IsNullOrEmpty($userData.TeamsCallingPolicy)) {$userData.TeamsCallingPolicy = ""} | |
$userData.TeamsUpgradePolicy = if([string]::IsNullOrEmpty($csUser.TeamsUpgradeEffectiveMode)) { "" } else {$csUser.TeamsUpgradeEffectiveMode} | |
$userData.TenantDialPlan = if([string]::IsNullOrEmpty($csUser.TenantDialPlan)) { "" } else {$csUser.TenantDialPlan} | |
$userData.OnlineVoiceRoutingPolicy = if([string]::IsNullOrEmpty($csUser.OnlineVoiceRoutingPolicy)) { "" } else {$csUser.OnlineVoiceRoutingPolicy} | |
# Teams Phone Number Type (requires User ID for Get-CsPhoneNumberAssignment) | |
$userData.TeamsPhoneType = "" # Default | |
if ($csUser.LineURI -and $coreUserObject.Id) { # Both LineURI and User ID must exist | |
try { $phoneAssignment = Get-CsPhoneNumberAssignment -AssignedPstnTargetId $coreUserObject.Id -ErrorAction SilentlyContinue; if ($phoneAssignment -and $phoneAssignment.NumberType) { $userData.TeamsPhoneType = $phoneAssignment.NumberType.ToString() } } | |
catch { Write-Warning ("Could not get phone number type for {0}: {1}" -f $csUser.LineURI, $_.Exception.Message); $userData.TeamsPhoneType = "[Error Type]" } | |
} elseif ($csUser.LineURI) { $userData.TeamsPhoneType = "[User ID missing for type lookup]" } | |
} else { | |
Write-Warning "Get-CsOnlineUser returned no object for ${UserUpn}" | |
$userData.TeamsError = "Not licensed for Teams or not found." | |
} | |
} catch { | |
#Write-Warning ("Error fetching Teams details for {0}. Assumed no license. Error: {1}" -f $UserUpn, $_.Exception.Message) | |
$userData.TeamsError = "Not found or no license (error)." | |
} | |
} | |
Write-Host ("Successfully loaded user data for {0}" -f $UserUpn) -ForegroundColor Green | |
} catch { | |
# Main catch block for Get-ComprehensiveUserDetails | |
$errorDetail = "Get-ComprehensiveUserDetails failed for UPN '$UserUpn': $($_.Exception.Message)" | |
Write-Warning ("CRITICAL ERROR in Get-ComprehensiveUserDetails for {0}: {1}`n{2}" -f $UserUpn, $_.Exception.Message, $_.ScriptStackTrace) | |
# Ensure the $userData object exists and has an Error key | |
if ($null -eq $userData) { $userData = @{} } | |
$userData.Error = $errorDetail | |
$userData.UserPrincipalName = $UserUpn # Ensure UPN is present even on error | |
$userData.DisplayName = "[Error]" # Default display name on error | |
# Ensure all expected keys exist in the returned object, even if an error occurred early | |
$allExpectedKeys = @( | |
"JobTitle", "Department", "ManagerDisplayName", "CompanyName", "UserType", "AccountEnabled", | |
"IsLicensed", "Licenses", "UsageLocation", "OnPremisesSyncEnabled", | |
"PasswordLastChangedDateTime", "LastSignInDateTime", "CreatedDateTime", | |
"PhysicalDeliveryOfficeName", "StreetAddress", "City", "State", "PostalCode", "Country", | |
"MobilePhone", "BusinessPhones", "FaxNumber", "MailNickname", "AboutMe", | |
"MfaMethods", "HasDirectReports", "DirectReportsList", "UserDevices", | |
"GroupMemberships", "RecipientTypeDetails", "MailboxQuota", "ForwardingSmtpAddress", | |
"DeliverToMailboxAndForward", "HiddenFromAddressLists", "ArchiveStatus", "ProxyAddresses", | |
"FullAccessDelegates", "SendAsDelegates", "SendOnBehalfDelegates", | |
"OofStatus", "OofExternalAudience", "OofScheduled", "OofStartTime", "OofEndTime", | |
"OofInternalReply", "OofExternalReply", | |
"TeamsPhoneNumber", "TeamsPhoneType", "TeamsEnterpriseVoice", "TeamsCallingPolicy", | |
"TeamsUpgradePolicy", "TenantDialPlan", "OnlineVoiceRoutingPolicy", | |
"TeamsError", "ExchangeError", "GraphError", "FirstName", "LastName", "Mail", "Notes", "Id", "MailDisplayName" | |
) | |
foreach($key in $allExpectedKeys){ | |
if(-not $userData.ContainsKey($key)){ | |
# Provide sensible defaults based on expected data type | |
if ($key -eq 'AccountEnabled' -or $key -eq 'IsLicensed' -or $key -eq 'OnPremisesSyncEnabled' -or $key -eq 'HasDirectReports') { $userData[$key] = $false } | |
elseif ($key -eq 'Licenses' -or $key -eq 'MfaMethods' -or $key -eq 'DirectReportsList' -or $key -eq 'UserDevices' -or $key -eq 'GroupMemberships' -or $key -eq 'ProxyAddresses' -or $key -eq 'FullAccessDelegates' -or $key -eq 'SendAsDelegates' -or $key -eq 'SendOnBehalfDelegates') { $userData[$key] = @() } | |
elseif ($key -eq 'PasswordLastChangedDateTime' -or $key -eq 'LastSignInDateTime' -or $key -eq 'CreatedDateTime' -or $key -eq 'OofStartTime' -or $key -eq 'OofEndTime' -or $key -eq 'DeliverToMailboxAndForward' -or $key -eq 'HiddenFromAddressLists') { $userData[$key] = $null } | |
else { $userData[$key] = "" } | |
} | |
} | |
} | |
return $userData # Always return the $userData hashtable | |
} | |
# --- GLOBAL STATE (Defined after functions, before HTML) --- | |
$Global:ConnectionState = @{ | |
Exchange = $false | |
Graph = $false | |
Teams = $false | |
} | |
$Global:Script:FetchedTenantName = "Company Name" # Default, updated on Graph connect | |
# Settings File Path | |
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path | |
$SettingsFilePath = Join-Path -Path $ScriptDir -ChildPath "settings.json" | |
# Default Settings | |
$DefaultSettings = @{ | |
accessMode = "read" # "read" or "readwrite" | |
theme = "light" # "light" or "dark" | |
backupPath = "" # Custom backup path, if empty, defaults to MyDocuments\UserDetailsDashboard_Backups | |
selectedBrowser = "chrome" # chrome, msedge, brave, opera, firefox | |
baseFontSize = "13px" # e.g., "12px", "14px" | |
fontFamily = "sans-serif" # e.g., "Segoe UI", "Roboto" | |
captureScaleFactor = 0.4 | |
} | |
# Load Settings or Initialize with Defaults | |
if (Test-Path $SettingsFilePath) { | |
try { | |
$Global:ScriptSettings = Get-Content -Path $SettingsFilePath -Raw | ConvertFrom-Json -ErrorAction Stop | |
# Ensure all expected keys exist, add if missing from file | |
foreach ($key in $DefaultSettings.Keys) { | |
if (-not $Global:ScriptSettings.PSObject.Properties.Name.Contains($key)) { | |
Write-Warning "Settings file missing key '$key'. Adding default value." | |
$Global:ScriptSettings | Add-Member -MemberType NoteProperty -Name $key -Value $DefaultSettings[$key] -Force | |
} | |
} | |
# Ensure specific potentially null string values are empty strings if null | |
if ($null -eq $Global:ScriptSettings.backupPath) { | |
$Global:ScriptSettings.backupPath = "" | |
} | |
if ($null -eq $Global:ScriptSettings.selectedBrowser) { | |
$Global:ScriptSettings.selectedBrowser = "chrome" | |
} | |
if ($null -eq $Global:ScriptSettings.baseFontSize) { | |
$Global:ScriptSettings.baseFontSize = "13px" | |
} | |
if ($null -eq $Global:ScriptSettings.fontFamily) { | |
$Global:ScriptSettings.fontFamily = "sans-serif" | |
} | |
} catch { | |
Write-Warning "Error reading settings file '$SettingsFilePath' or file is corrupt. Using default settings and overwriting. Error: $($_.Exception.Message)" | |
$Global:ScriptSettings = $DefaultSettings | |
$Global:ScriptSettings | ConvertTo-Json -Depth 5 | Set-Content -Path $SettingsFilePath -Encoding UTF8 -Force | |
} | |
} else { | |
Write-Output "Settings file not found at '$SettingsFilePath'. Creating with default settings." | |
$Global:ScriptSettings = $DefaultSettings | |
$Global:ScriptSettings | ConvertTo-Json -Depth 5 | Set-Content -Path $SettingsFilePath -Encoding UTF8 -Force | |
} | |
# Determine effective view-only mode for HTML templating based on loaded settings | |
# PowerShell uses $true/$false for booleans, JavaScript uses true/false. This is for PS templating. | |
$effectiveViewOnlyMode = $true | |
if ($Global:ScriptSettings.accessMode -eq "readwrite") { | |
$effectiveViewOnlyMode = $false | |
} | |
# HTML page served to browser------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
#----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
$html = @" | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="UTF-8"> | |
<title>User Details Dashboard</title> | |
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet"> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
<style> | |
.hidden { display: none !important; } | |
body { font-family: sans-serif; margin: 15px; background-color: #f4f7f6; font-size: 13px; color: #212529; } | |
.button-bar { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 6px; align-items: center; padding: 6px; background-color: #e9ecef; border-radius: 4px; margin-bottom: 4px; } | |
button { padding: 5px 10px; font-size: 0.92em; /* 12px relative to 13px body */ background-color: #ffffff; border: 1px solid #ced4da; border-radius: 3px; cursor: pointer; transition: background-color 0.2s ease-in-out; margin-right: 3px; white-space: nowrap; } | |
button:hover { background-color: #f8f9fa; } | |
.connection-buttons-group, .action-buttons-group, .user-search-group, .single-button-group { display: flex; gap: 4px; padding: 4px; border: 1px solid #adb5bd; border-radius: 3px; margin-right: 8px; align-items: center; } | |
.right-align-group { margin-left: auto; display: flex; align-items: center; gap: 6px; } | |
input[type="text"]#usersearch { padding: 5px; font-size: 0.92em; /* 12px */ width: 220px; border: 1px solid #ced4da; border-radius: 3px; margin-right: 0; } | |
select.action-dropdowns { padding: 4px; font-size: 0.92em; /* 12px */ border: 1px solid #ced4da; border-radius: 3px; background-color: white; } | |
#status_strip_container { width: 100%; padding: 2px 0px; margin-top: 4px; margin-bottom: 6px; background-color: #f0f0f0; border-top: 1px solid #dcdcdc; border-bottom: 1px solid #dcdcdc; text-align: center; min-height: 1em; line-height: 1em; } | |
#dashboard_status_message { font-style: italic; color: #6c757d; font-size: 0.846em; /* 11px */ display: inline; } | |
#dashboard_status_message.status-error { color: #dc3545; font-weight: bold; } | |
#dashboard_status_message.status-success { color: #28a745; font-weight: bold; } | |
#userNameHeader { font-size: 1.69em; /* 22px */ font-weight: bold; text-align: center; margin-bottom: 1px; color: #343a40; min-height: 1em; } | |
#tenantHeader { font-size: 1.23em; /* 16px */ text-align: center; margin-bottom: 8px; color: #495057; min-height: 1em; } | |
.columns-container { display: flex; flex-wrap: wrap; gap: 10px; align-items: stretch; } | |
.column { flex: 1; min-width: 240px; background-color: #ffffff; border: 1px solid #dee2e6; padding: 8px; border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.03); } | |
.column#column4 { display: flex; flex-direction: column; } | |
.info-group { margin-bottom: 12px; } | |
.info-group h3 { margin-top: 0; margin-bottom: 7px; font-size: 1.07em; /* 14px */ color: #007bff; border-bottom: 1px solid #007bff; padding-bottom: 4px; transition: color 0.3s, border-color 0.3s;} | |
.info-group > div.label-value-pair { display: flex; align-items: flex-start; margin-bottom: 5px; line-height: 1.5; font-size: 0.92em; /* 12px */ } | |
.info-group > div > .label { flex-shrink: 0; min-width: 110px; margin-right: 4px; transition: color 0.3s; color: #212529;} | |
.column#column4 .info-group > div > .label { min-width: 150px; } | |
#oof_details_content_wrapper .label-value-pair > .label, | |
#direct_reports_content_wrapper .label-value-pair > .label { min-width: 120px; } | |
.info-group > div > .value { flex-grow: 1; word-break: break-word; overflow-wrap: break-word; color: #000000;} | |
.info-group > div.label-block-value > .label { display: block; margin-bottom: 1px; } | |
.info-group pre, .info-group label { margin-bottom: 4px; line-height: 1.5; font-size: 0.92em; /* 12px */ } | |
.label { font-weight: bold; color: #212529; display: inline-block; vertical-align: top; } | |
.value { display: inline-block; color: #000000; min-height: 1em; } | |
/* Specific Label Font Sizes --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */ | |
#label_licenses, | |
#label_aliases, | |
#labelgroup_notes > .label, | |
#labelgroup_fullaccess > .label, | |
#labelgroup_sendas > .label, | |
#labelgroup_sendonbehalf > .label, | |
#labelgroup_mfamethods > .label, | |
#oof_details_content .label-value-pair > .label, | |
#oof_details_content > div:has(> .oof-message) > .label { | |
font-size: 0.846em !important; /* 11px relative to 13px body */ | |
} | |
/* --- Global - Dark Theme Styles -----------------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
body.theme-dark { background-color: #121212; color: #e0e0e0; } | |
body.theme-dark .button-bar { background-color: #2d2d2d; border-color: #444; } | |
body.theme-dark button { background-color: #3e3e3e; border-color: #555; color: #e0e0e0; } | |
body.theme-dark button:hover { background-color: #505050; } | |
body.theme-dark button[style*="background-color: lightgreen"] { color: #111 !important; } | |
body.theme-dark .connection-buttons-group, | |
body.theme-dark .action-buttons-group, | |
body.theme-dark .user-search-group, | |
body.theme-dark .single-button-group { border-color: #555; } | |
body.theme-dark input[type="text"]#usersearch, | |
body.theme-dark select.action-dropdowns, | |
body.theme-dark .modal-content .setting-group select { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #status_strip_container { background-color: #1e1e1e; border-color: #333; } | |
body.theme-dark #dashboard_status_message { color: #aaa; } | |
body.theme-dark #dashboard_status_message.status-error { color: #ff8a80; } | |
body.theme-dark #dashboard_status_message.status-success { color: #a5d6a7; } | |
body.theme-dark #userNameHeader { color: #e0e0e0; } | |
body.theme-dark #tenantHeader { color: #b0b0b0; } | |
body.theme-dark .column { background-color: #1e1e1e; border-color: #333; } | |
body.theme-dark .info-group h3 { color: #81d4fa; border-bottom-color: #81d4fa; } | |
body.theme-dark .info-group > div > .label { color: #c0c0c0; } | |
body.theme-dark .info-group > div > .value { color: #e0e0e0; } | |
body.theme-dark #detail_notes.value, | |
body.theme-dark .scrollable-list-short, | |
body.theme-dark .scrollable-list-medium, | |
body.theme-dark .oof-message, | |
body.theme-dark .direct-reports-message { background-color: #2a2a2a; border-color: #444; color: #d0d0d0; } | |
body.theme-dark #detail_groupMemberships { background-color: #2a2a2a; border-color: #444; color: #d0d0d0; } | |
body.theme-dark hr.section-divider { border-top-color: #444; } | |
body.theme-dark .modal-content { background-color: #2d2d2d; border-color: #555; color: #e0e0e0; } | |
body.theme-dark .modal-content h2 { color: #e0e0e0; } | |
body.theme-dark .modal-content .setting-group label { color: #c0c0c0; } /* Specific to settings modal */ | |
body.theme-dark .modal-content .setting-group label .settings-hint { color: #888; } /* Specific to settings modal */ | |
body.theme-dark .modal-content input[type="radio"] + label { color: #d0d0d0; } /* Specific to settings modal */ | |
body.theme-dark .modal-content input[type="text"] { background-color: #333; border-color: #555; color: #e0e0e0;} /* Specific to settings modal */ | |
body.theme-dark #oof_details_content_wrapper .label, | |
body.theme-dark #oof_details_content_wrapper .value, | |
body.theme-dark #oof_details_content .label, | |
body.theme-dark #oof_details_content .value { | |
color: #e0e0e0 !important; | |
} | |
body.theme-dark #oof_details_content_wrapper .label, | |
body.theme-dark #oof_details_content .label { | |
color: #c0c0c0 !important; | |
} | |
body.theme-dark #direct_reports_content_wrapper .label, | |
body.theme-dark #direct_reports_content_wrapper #detail_directReportsStatus.value { | |
color: #e0e0e0 !important; | |
} | |
body.theme-dark #direct_reports_content_wrapper .label { | |
color: #c0c0c0 !important; | |
} | |
/* --- html2canvas Capture -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
@media print { | |
body.print-details-mode .button-bar, | |
body.print-details-mode #status_strip_container, | |
body.print-details-mode hr.section-divider:first-of-type, | |
body.print-details-mode .info-group button, | |
body.print-details-mode .group-controls button, | |
body.print-details-mode .group-sort-options, | |
body.print-details-mode #loadSharedMailboxAccessBtn | |
{ | |
display: none !important; | |
} | |
body.print-details-mode #captureArea { | |
display: block !important; | |
margin: 0 !important; | |
padding: 0 !important; | |
box-shadow: none !important; | |
border: none !important; | |
} | |
body.print-details-mode .columns-container { | |
gap: 5px !important; | |
} | |
body.print-details-mode .column { | |
min-width: 0 !important; | |
padding: 5px !important; | |
box-shadow: none !important; | |
border: 1px solid #ccc !important; | |
} | |
body.print-details-mode .scrollable-list-short, | |
body.print-details-mode .scrollable-list-medium, | |
/* #detail_groupMemberships handled by onclone for screenshot */ | |
body.print-details-mode .oof-message, | |
body.print-details-mode .direct-reports-message { | |
max-height: none !important; | |
height: auto !important; | |
overflow-y: visible !important; | |
border: 1px solid #eee; | |
} | |
body.print-details-mode #detail_notes.value { | |
max-height: none !important; | |
height: auto !important; | |
overflow-y: visible !important; | |
} | |
} | |
/* --- Global - Modal Styles (Shared by Settings and Manage Modals) -----------------------------------------------------------------------------------------------------------------------------------*/ | |
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); } | |
.modal-content { background-color: #fefefe; margin: 5% auto; padding: 20px; border: 1px solid #888; width: 80%; max-width: 500px; border-radius: 5px; font-size: 1em; /* Relative to body font size */ } | |
.modal-content h2 { margin-top: 0; font-size: 1.38em; /* ~18px */ } | |
.modal-content .setting-group { margin-bottom: 15px; } /* Specific to settings modal */ | |
.modal-content .setting-group label { display: block; margin-bottom: 5px; font-weight: bold; } /* Specific to settings modal */ | |
.modal-content .setting-group input[type="radio"], | |
.modal-content .setting-group input[type="text"], | |
.modal-content .setting-group select { margin-right: 5px; font-size: 1em; } /* Specific to settings modal */ | |
.modal-content .setting-group input[type="text"], | |
.modal-content .setting-group select { width: calc(100% - 12px); padding: 6px; border-radius: 3px; border: 1px solid #ccc; box-sizing: border-box;} /* Specific to settings modal */ | |
.modal-content .setting-group label .settings-hint { font-weight: normal; font-size: 0.9em; color: #555; margin-left: 2px; } /* Specific to settings modal */ | |
.modal-content .modal-buttons { text-align: right; margin-top: 20px; } | |
.modal-content .modal-buttons button { margin-left: 10px; } | |
.modal-buttons { | |
display: flex; | |
justify-content: flex-end; | |
gap: 10px; | |
margin-top: 15px; | |
} | |
#stopServerBtn { | |
margin-right: auto; | |
} | |
/* --- Portals - Styles for Portals Modal ------------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
.portal-list-container { display: flex; flex-direction: column; gap: 8px; margin-top: 15px; max-height: 60vh; overflow-y: overflow-x: hidden; auto; } | |
.portal-list-container button.portal-link-button { display: block; width: 100%; text-align: left; padding: 10px 8px; margin-bottom: 0; box-sizing: border-box; white-space: normal; overflow: hidden; text-overflow: ellipsis; } | |
/* Dark Theme for Portal Link Buttons */ | |
/* Typically, they will inherit body.theme-dark button styles, which is usually fine. */ | |
body.theme-dark .portal-list-container button.portal-link-button { background-color: #4a4a4a; border-color: #666; } | |
body.theme-dark .portal-list-container button.portal-link-button:hover { background-color: #5a5a5a; } | |
/* --- PSCOMMAND Styles ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
#psCommandInput { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 3px; font-family: Consolas, "Courier New", monospace; font-size: 0.9em; line-height: 1.4; } | |
#psCommandOutput { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 3px; font-family: Consolas, "Courier New", monospace; font-size: 0.9em; line-height: 1.4; background-color: #f8f9fa; white-space: pre-wrap !important; } | |
/* Dark Theme */ | |
body.theme-dark #psCommandInput { background-color: #2a2a2a; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #psCommandOutput { background-color: #222; border-color: #555; color: #e0e0e0; white-space: pre-wrap !important; } | |
* Checker Styles ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */ | |
.label-correct { color: #28a745 !important; } | |
.label-incorrect { color: #dc3545 !important; } | |
.title-correct { color: #28a745 !important; border-bottom-color: #28a745 !important; } | |
.title-incorrect { color: #dc3545 !important; border-bottom-color: #dc3545 !important; } | |
#detail_notes.value { | |
font-size: 0.77em; /* 10px */ | |
max-height: 45px; | |
overflow-y: auto; | |
white-space: pre-wrap; | |
display: block; | |
border: 1px solid #f0f0f0; | |
padding: 3px; | |
background-color: #f8f9fa; | |
border-radius: 3px; | |
} | |
.scrollable-list-short, .scrollable-list-medium { min-height: 20px; background-color: #f8f9fa; border: 1px solid #ced4da; padding: 4px; font-size: 0.77em; /* 10px */ border-radius: 3px; overflow-y: auto; width:auto; display:block; } | |
.scrollable-list-short { max-height: 60px; } | |
.scrollable-list-medium { max-height: 90px; } | |
.device-item { display: flex; justify-content: space-between; padding: 1px 0; border-bottom: 1px dotted #eee; font-size: 0.846em; /* 11px */ } | |
.device-item:last-child { border-bottom: none; } | |
.device-name { flex-basis: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
.device-os { flex-basis: 35%; text-align: right; color: #6c757d; } | |
#detail_groupMemberships { width: calc(100% - 10px); height: 380px; font-family: Consolas, "Courier New", monospace; font-size: 0.846em; /* 11px */ white-space: pre; resize: vertical; border: 1px solid #ced4da; padding: 4px; border-radius: 3px;} | |
.group-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } | |
.group-sort-options { font-size: 0.846em; /* 11px */ display: flex; align-items: center; } | |
.group-sort-options label { display: inline-block; margin: 0; font-weight:normal; line-height:normal; vertical-align: middle; margin-right: 2px;} | |
.group-sort-options select { display: inline-block; padding: 2px; font-size: 0.846em; /* 11px */ vertical-align: middle;} | |
.oof-message { white-space: pre-wrap; padding: 6px; background-color: #f8f9fa; border: 1px dashed #ced4da; margin-top: 4px; max-height: 110px; overflow-y: auto; border-radius: 3px; font-size: 0.77em; /* 10px */ min-height: 1.3em; } | |
.direct-reports-message { white-space: pre-wrap; padding: 6px; background-color: #f8f9fa; border: 1px dashed #ced4da; margin-top: 4px; max-height: 110px; overflow-y: auto; border-radius: 3px; font-size: 0.846em; /* 11px */ min-height: 1.3em; } | |
hr.section-divider { margin: 7px 0; border: 0; border-top: 1px solid #dee2e6; } | |
/* --- Manage Modal - Specific Styles ---------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
#manageModal .modal-content { max-width: 750px; } | |
#manageModal .category-buttons button { margin: 0 5px 10px 0; } | |
#manageModal .category-buttons button.active-category { background-color: #007bff; color: white; border-color: #007bff;} | |
#manageModal .category-content { display: none; padding: 10px; border: 1px solid #ddd; border-radius: 4px; margin-top: 5px; background-color: #f9f9f9;} | |
#manageModal .category-content.active { display: block; } | |
#manageGraphConnectStatus { margin-left: 10px; font-style: italic; font-size: 0.9em; } | |
#manageCatGeneralContent h4, #manageCatOffboardingContent h4, #manageCatOnboardingContent h4 { font-size: 1.1em; margin-top: 15px; margin-bottom: 8px; color: #0056b3; border-bottom: 1px solid #0056b3; padding-bottom: 3px;} | |
#manageCatGeneralContent h4:first-of-type, #manageCatOffboardingContent h4:first-of-type, #manageCatOnboardingContent h4:first-of-type { margin-top: 0; } | |
#manageCatGeneralContent button, #manageCatOffboardingContent button, #manageCatOnboardingContent button { margin-right: 5px; margin-bottom: 5px; } | |
#passwordGeneratorArea, #onboardPasswordGeneratorArea { margin-top: 10px; padding:10px; border: 1px dashed #ccc; border-radius:3px; background-color:#f9f9f9;} | |
#generatedPassword, #onboardGeneratedPasswordDisplay { font-family: monospace; background-color: #fff; padding: 5px; border: 1px solid #ccc; display: inline-block; min-width: 150px; margin-right:10px; } | |
#manageCatOffboardingContent .offboarding-action-group { margin-bottom: 10px; padding: 5px; border: 1px solid #eee; border-radius: 3px;} | |
#manageCatOffboardingContent .offboarding-action-group label, #manageCatOnboardingContent .onboarding-form-group label { display: block; margin-bottom: 3px; font-weight:normal; } | |
#manageCatOffboardingContent .offboarding-action-group input[type="checkbox"] { margin-right: 5px; vertical-align: middle; } | |
#manageCatOffboardingContent .offboarding-action-group .action-details { display: none; margin-left: 25px; margin-top: 5px; padding: 8px; background-color: #f0f0f0; border-radius: 3px;} | |
#manageCatOffboardingContent .offboarding-action-group .action-details input[type="text"], | |
#manageCatOffboardingContent .offboarding-action-group .action-details textarea, | |
#manageCatOffboardingContent .offboarding-action-group .action-details select { width: calc(100% - 16px); margin-bottom: 5px; padding: 4px; font-size:0.9em; } | |
#manageCatOffboardingContent .offboarding-action-group .action-details button { font-size: 0.85em; padding: 3px 6px; } | |
#manageCatOffboardingContent .offboarding-top-buttons button { margin-right: 10px;} | |
#manageCatOnboardingContent .onboarding-form-group { margin-bottom: 12px; } | |
#manageCatOnboardingContent .onboarding-form-group input[type="text"], | |
#manageCatOnboardingContent .onboarding-form-group input[type="password"], | |
#manageCatOnboardingContent .onboarding-form-group select, | |
#manageCatOnboardingContent .onboarding-form-group textarea { width: calc(100% - 10px); padding: 6px; border-radius: 3px; border: 1px solid #ccc; box-sizing: border-box; font-size: 0.95em;} | |
#manageCatOnboardingContent .onboarding-form-group .inline-picker { display: flex; align-items: center; } | |
#manageCatOnboardingContent .onboarding-form-group .inline-picker input[type="text"] { flex-grow: 1; margin-right: 5px; } | |
#manageCatOnboardingContent .onboarding-form-group .inline-picker button { flex-shrink: 0; } | |
#onboardUpnCheckResult { font-size: 0.85em; margin-left: 5px; } | |
/* --- Manage - Dark theme for Manage Modal category buttons and content -------------------------------------------------------------------------------------------------------------------------------*/ | |
body.theme-dark #manageModal .category-buttons button.active-category { background-color: #81d4fa; color: #121212; border-color: #81d4fa;} | |
body.theme-dark #manageModal .category-content { border-color: #555; background-color: #252525;} | |
body.theme-dark #manageCatGeneralContent h4, body.theme-dark #manageCatOffboardingContent h4, body.theme-dark #manageCatOnboardingContent h4 { color: #81d4fa; border-bottom-color: #81d4fa;} | |
body.theme-dark #passwordGeneratorArea, body.theme-dark #onboardPasswordGeneratorArea { background-color:#333; border-color: #555;} | |
body.theme-dark #generatedPassword, body.theme-dark #onboardGeneratedPasswordDisplay { background-color: #444; border-color: #666; color: #e0e0e0;} | |
body.theme-dark #manageCatOffboardingContent .offboarding-action-group { border-color: #444; } | |
body.theme-dark #manageCatOffboardingContent .offboarding-action-group .action-details { background-color: #333; } | |
body.theme-dark #manageCatOffboardingContent .offboarding-action-group .action-details input[type="text"], | |
body.theme-dark #manageCatOffboardingContent .offboarding-action-group .action-details textarea, | |
body.theme-dark #manageCatOffboardingContent .offboarding-action-group .action-details select { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #manageCatOnboardingContent .onboarding-form-group input[type="text"], | |
body.theme-dark #manageCatOnboardingContent .onboarding-form-group input[type="password"], | |
body.theme-dark #manageCatOnboardingContent .onboarding-form-group select, | |
body.theme-dark #manageCatOnboardingContent .onboarding-form-group textarea { background-color: #333; border-color: #555; color: #e0e0e0;} | |
body.theme-dark .modal-status-container { background-color: #2d2d2d !important; border: 1px solid #444 !important;} | |
body.theme-dark #manageModalStatusMessage { color: #aaa !important;} | |
body.theme-dark #resetPasswordContainer { background-color: #333 !important; border-color: #555 !important;} | |
body.theme-dark #resetPasswordContainer label { color: #e0e0e0 !important;} | |
body.theme-dark #newPasswordInput { background-color: #252525 !important; border-color: #555 !important; color: #e0e0e0 !important;} | |
/* Definitive Styles for the Manage User Modal Status Strip */ | |
.modal-status-container { | |
display: block; /* Ensure it is visible by default */ | |
padding: 5px; | |
min-height: 1.2em; /* This is the key to preventing the "thin strip" look */ | |
text-align: center; | |
border-radius: 3px; | |
margin-bottom: 10px; | |
background-color: #e9ecef; | |
} | |
* --- Manage Modal - Styles for Status Strip ---*/ | |
.modal-status-container { | |
display: block; /* Ensure it is visible by default */ | |
padding: 5px; | |
min-height: 1.2em; | |
text-align: center; | |
border-radius: 3px; | |
margin-bottom: 10px; | |
background-color: #e9ecef; /* Light theme background */ | |
} | |
/* --- Edit User Details - Styles for Edit User Details Modal ----------------------------------------------------------------------------------------------------------------------------------- */ | |
.edit-user-form-container { max-height: 60vh; overflow-y: auto; margin-top: 15px; padding-right: 10px; } | |
.edit-user-table { width: 100%; border-collapse: collapse; } | |
.edit-user-table th, .edit-user-table td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 0.9em; } | |
.edit-user-table th { background-color: #f2f2f2; } | |
.edit-user-table .section-header { background-color: #e9ecef; font-weight: bold; text-align: center; } | |
.edit-user-table .field-label { width: 20%; font-weight: bold; } | |
.edit-user-table .field-input { width: 45%; } | |
.edit-user-table .field-current-value { width: 35%; background-color: #f8f9fa; font-style: italic; color: #6c757d; word-break: break-all; } | |
.edit-user-table input[type="text"], .edit-user-table select, .edit-user-table textarea { width: 100%; padding: 4px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 3px; } | |
.notes-display { max-height: 80px; overflow-y: auto; white-space: pre-wrap; } | |
/* --- Edit User Details - Dark Theme for Edit User Details Modal */ | |
body.theme-dark .edit-user-table th, body.theme-dark .edit-user-table td { border-color: #444; } | |
body.theme-dark .edit-user-table th { background-color: #2d2d2d; } | |
body.theme-dark .edit-user-table .section-header { background-color: #252525; } | |
body.theme-dark .edit-user-table .field-current-value { background-color: #2a2a2a; color: #aaa; } | |
body.theme-dark .edit-user-table input[type="text"], body.theme-dark .edit-user-table select, body.theme-dark .edit-user-table textarea { background-color: #333; border-color: #555; color: #e0e0e0; } | |
/* --- Edit User Details - Styles for Hint next to Edit User Details button ---*/ | |
.inline-action-with-hint { display: flex; align-items: baseline; gap: 15px; margin-bottom: 5px; } | |
.sync-status-badge { font-size: 0.8em; font-weight: bold; padding: 4px 10px; border-radius: 12px; border: 1px solid transparent; cursor: default; } | |
.sync-status-badge.cloud { background-color: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; } | |
.sync-status-badge.synced { background-color: #fff3e0; color: #ef6c00; border-color: #ffcc80; } | |
body.theme-dark .sync-status-badge.cloud { background-color: #1a311b; color: #a5d6a7; border-color: #4CAF50; } | |
body.theme-dark .sync-status-badge.synced { background-color: #4e342e; color: #ffcc80; border-color: #ff9800; } | |
.status-error { color: #dc3545; font-weight: bold; } | |
.status-success { color: #28a745; font-weight: bold; } | |
.status-info { color: #6c757d; } | |
body.theme-dark .status-error { color: #ff8a80; } | |
body.theme-dark .status-success { color: #a5d6a7; } | |
body.theme-dark .status-info { color: #aaa; } | |
/* --- Manage Groups - Styles for Manage Groups Modal Layout -----------------------------------------------------------------------------------------------------------------------*/ | |
.groups-modal-listbox { | |
width: 100%; | |
height: 500px; | |
border: 1px solid #ccc; | |
border-radius: 3px; | |
padding: 5px; | |
box-sizing: border-box; | |
font-size: 0.9em; | |
} | |
.groups-modal-listbox.short-groups-listbox { | |
height: 220px; | |
} | |
/* Dark Theme specific for these listboxes */ | |
body.theme-dark .groups-modal-listbox { | |
background-color: #252525; | |
border-color: #555; | |
color: #e0e0e0; | |
} | |
.group-management-container-new { display: flex; gap: 15px; margin-top: 15px; } | |
.group-panel-new { flex: 1; display: flex; flex-direction: column; padding: 10px; border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9; } | |
.group-panel-new h4, .group-panel-new h5 { margin-top: 0; margin-bottom: 5px; } | |
.group-panel-new .button-group { margin-top: 8px; display: flex; gap: 5px; flex-wrap: wrap; } | |
.group-panel-new .inline-picker { display: flex; gap: 5px; } | |
.group-panel-new .inline-picker input { flex-grow: 1; } | |
.sub-panel-new { display: flex; flex-direction: column; flex-grow: 1; } | |
.sub-panel-new:first-of-type { margin-bottom: 10px; } | |
.destructive-button { background-color: #fce4e4; border-color: #e57373; } | |
/* CSS to align the tops of the group listboxes */ | |
.listbox-aligner { height: 31px; /* Adjust this value if needed to perfectly align */ } | |
/* Dark Theme for NEW Manage Groups Modal */ | |
body.theme-dark .group-panel-new { background-color: #2a2a2a; border-color: #444; } | |
body.theme-dark .group-listbox-new { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
body.theme-dark .destructive-button { background-color: #612a2a; border-color: #a04444; } | |
/* --- Manage Licenses - Styles for Manage Licenses Modal -----------------------------------------------------------------------------------------------------------------------------*/ | |
.license-management-container { display: flex; gap: 15px; margin-top: 15px; } | |
.license-panel { flex: 1; display: flex; flex-direction: column; } | |
.license-panel h4, .license-panel h5 { margin-top: 0; margin-bottom: 5px; } | |
.license-panel .sub-panel { margin-bottom: 10px; } | |
.license-listbox { width: 100%; height: 160px; border: 1px solid #ccc; border-radius: 3px; padding: 5px; box-sizing: border-box; font-size: 0.9em; } | |
.license-panel button { margin-top: 5px; } | |
.license-listbox option.disabled-plan-parent { font-weight: bold; } | |
.license-listbox option.disabled-plan-child { padding-left: 15px; color: #777; } | |
/* Dark Theme for Manage Licenses Modal */ | |
body.theme-dark .license-listbox { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
body.theme-dark .license-listbox option.disabled-plan-child { color: #999; } | |
body.theme-dark #filterAvailableLicenses { background-color: #333; border-color: #555; color: #e0e0e0; } | |
/* --- Manage Alias - Styles for Manage Aliases Modal -------------------------------------------------------------------------------------------------------------------------------------*/ | |
.aliases-management-container { display: flex; flex-direction: column; gap: 15px; margin-top: 15px; } | |
.aliases-panel { padding: 10px; border: 1px solid #ccc; border-radius: 4px; background-color: #f9f9f9; } | |
.aliases-panel h4 { margin-top: 0; margin-bottom: 10px; } | |
.aliases-listbox { width: 100%; border: 1px solid #ccc; border-radius: 3px; padding: 5px; box-sizing: border-box; font-size: 0.9em; } | |
.aliases-panel .alias-actions-buttons button { margin-top: 5px; margin-right: 5px; } | |
.aliases-panel .inline-picker input[type="text"] { flex-grow: 1; } | |
.aliases-panel .inline-picker select { min-width: 150px; } | |
/* Dark Theme for Manage Aliases Modal */ | |
body.theme-dark .aliases-panel { background-color: #333; border-color: #555; } | |
body.theme-dark .aliases-listbox { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
body.theme-dark .aliases-panel .inline-picker input[type="text"], body.theme-dark .aliases-panel .inline-picker select { background-color: #2a2a2a; border-color: #555; color: #e0e0e0; } | |
/* --- Manage Autoreply - Styles for Manage Autoreply Modal ----------------------------------------------------------------------------------------------------------------------------------*/ | |
.autoreply-form-container { margin-top: 15px; } | |
.autoreply-form-container .form-group { margin-bottom: 12px; } | |
.autoreply-form-container .form-group label { display: block; margin-bottom: 4px; font-weight: bold; } | |
.autoreply-form-container .radio-group label { font-weight: normal; display: inline-block; margin-right: 10px; } | |
.autoreply-form-container .radio-group input[type="radio"] { margin-right: 3px; vertical-align: middle; } | |
.autoreply-form-container input[type="datetime-local"], .autoreply-form-container select, .autoreply-form-container textarea { width: 100%; padding: 6px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 3px; font-size: 0.95em; } | |
.autoreply-form-container .form-group-nested { padding-left: 20px; border-left: 2px solid #eee; margin-top: 5px; margin-bottom: 10px; } | |
#detail_oofStatus.value {font-size: 0.846em !important; /* 11px relative to 13px body */} | |
/* Dark Theme for Manage Autoreply Modal */ | |
body.theme-dark .autoreply-form-container input[type="datetime-local"], body.theme-dark .autoreply-form-container select, body.theme-dark .autoreply-form-container textarea { background-color: #333; border-color: #555; color: #e0e0e0; } | |
body.theme-dark .autoreply-form-container .form-group-nested { border-left-color: #444; } | |
/* --- Combined Modal - Styles for Combined Delegation & Forwarding Modal -------------------------------------------------------------------------------------------------------------------------------*/ | |
.tab-buttons { margin-bottom: 15px; border-bottom: 1px solid #ccc; padding-bottom: 5px; } | |
.tab-button { background-color: #f0f0f0; border: 1px solid #ccc; padding: 8px 12px; cursor: pointer; margin-right: 5px; border-bottom: none; border-radius: 4px 4px 0 0; } | |
.tab-button.active-tab { background-color: #fff; border-bottom: 1px solid #fff; position: relative; top: 1px; } | |
.tab-content { display: none; padding-top: 10px; } | |
.tab-content.active-content { display: block; } | |
.delegation-sections-container { display: flex; gap: 15px; } | |
.delegation-current-section, .delegation-add-section, .delegation-staged-section { flex: 1; padding:10px; border: 1px solid #eee; border-radius: 3px; background-color: #fdfdfd;} | |
.delegation-listbox { width: 100%; height: 120px; margin-bottom: 5px; border: 1px solid #ccc; border-radius: 3px; padding: 3px; font-size:0.9em; } | |
.delegation-add-section .inline-picker { margin-bottom: 10px; } | |
.delegation-add-section .permission-checkboxes label { font-weight: normal; display: block; margin-bottom: 3px; } | |
.delegation-add-section .permission-checkboxes input { vertical-align: middle; margin-right: 4px; } | |
.small-button { font-size: 0.85em; padding: 3px 6px; } | |
#forwardingContentDiv .form-group { margin-bottom: 10px; } | |
#forwardingContentDiv .form-group label { display: inline-block; margin-right: 10px; font-weight: bold; } | |
#forwardingContentDiv .form-group input[type="text"] { width: calc(100% - 120px); } /* Adjust based on label width */ | |
#forwardingContentDiv .form-group input[type="radio"], #forwardingContentDiv .form-group input[type="checkbox"] { vertical-align: middle; margin-right: 3px; } | |
#forwardingContentDiv .form-group .inline-picker label { width: auto; margin-right: 5px;} | |
#forwardingContentDiv .form-group-nested { padding-left: 20px; border-left: 2px solid #eee; margin-top: 5px; margin-bottom: 10px; } | |
/* Dark Theme for Combined Modal */ | |
body.theme-dark .tab-button { background-color: #333; border-color: #555; color: #ccc; } | |
body.theme-dark .tab-button.active-tab { background-color: #2d2d2d; border-color: #555 #555 #2d2d2d #555; } | |
body.theme-dark .delegation-current-section, body.theme-dark .delegation-add-section, body.theme-dark .delegation-staged-section { background-color: #2a2a2a; border-color: #444;} | |
body.theme-dark .delegation-listbox { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #forwardingContentDiv .form-group input[type="text"] { background-color: #333; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #forwardingContentDiv .form-group-nested { border-left-color: #444; } | |
/* --- Manage Teams - Styles for Manage Teams Phone Modal -----------------------------------------------------------------------------------------------------------------------------------------------*/ | |
.teams-phone-sections-container { display: flex; flex-direction: column; gap: 15px; margin-top: 15px; } | |
.teams-phone-panel { padding: 10px; border: 1px solid #ccc; border-radius: 4px; background-color: #f9f9f9; } | |
.teams-phone-panel h4 { margin-top: 0; margin-bottom: 10px; } | |
.teams-phone-panel .form-group { margin-bottom: 10px; } | |
.teams-phone-panel .form-group label { display: block; margin-bottom: 3px; font-weight: bold; } | |
.teams-phone-panel .form-group input[type="text"], .teams-phone-panel .form-group select { width: 100%; padding: 6px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 3px; font-size: 0.95em; } | |
.teams-phone-panel .form-group input[type="checkbox"] { vertical-align: middle; margin-right: 4px; } | |
.teams-phone-panel .form-group input[type="checkbox"] + label { font-weight: normal; } | |
.teams-phone-panel .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 5px; } | |
.teams-phone-listbox { width: 100%; border: 1px solid #ccc; border-radius: 3px; padding: 3px; font-size:0.9em; } | |
.teams-phone-panel .form-grid > div { display: flex; align-items: center; margin-bottom: 5px; } | |
.teams-phone-panel .form-grid > div label { margin-right: 8px; flex-basis: 35%; flex-shrink: 0; text-align: right; font-weight: bold; white-space: normal; overflow-wrap: break-word; word-break: break-word; font-size: 0.85em; } | |
.teams-phone-panel .form-grid > div input[type="text"] { flex-grow: 1; flex-basis: 60%; width: auto; min-width: 0; box-sizing: border-box; } | |
/* Dark theme adjustments if needed for the new label alignment (usually inherits fine) */ | |
.theme-dark .teams-phone-panel .form-grid > div label { /* No specific color change needed if general label color is fine */ } | |
/* Dark Theme for Manage Teams Phone Modal */ | |
body.theme-dark .teams-phone-panel { background-color: #333; border-color: #555; } | |
body.theme-dark .teams-phone-panel .form-group input[type="text"], body.theme-dark .teams-phone-panel .form-group select { background-color: #2a2a2a; border-color: #555; color: #e0e0e0; } | |
body.theme-dark .teams-phone-listbox { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
/* --- ONBOARDING - Styles for Onboarding ---------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
/* Styles for Onboarding Hint Text */ | |
.onboarding-hint-text { display: block; font-size: 0.8em; color: #007bff; margin-top: 2px; padding-left: 5px; font-style: italic; } | |
/* Dark Theme for Onboarding Hint Text */ | |
body.theme-dark .onboarding-hint-text { color: #81d4fa; } | |
/* Styles for Pick License Sub-Modal */ | |
#filterPickableLicenses { border: 1px solid #ccc; border-radius: 3px; } | |
#pickableLicensesList { border: 1px solid #ccc; border-radius: 3px; padding: 5px; font-size: 0.9em; } | |
/* Dark Theme for Pick License Sub-Modal */ | |
body.theme-dark #filterPickableLicenses { background-color: #333; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #pickableLicensesList { background-color: #252525; border-color: #555; color: #e0e0e0; } | |
/* Styles for Custom Dropdown Suggestions */ | |
.custom-dropdown-content { | |
display: none; /* Hidden by default */ | |
position: absolute; | |
background-color: #f6f6f6; | |
min-width: 250px; /* Match or be wider than the search box */ | |
width: 100%; /* Make it the same width as the search box container */ | |
max-height: 200px; | |
overflow-y: auto; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
z-index: 100; | |
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); | |
} | |
.custom-dropdown-content div { | |
color: black; | |
padding: 8px 12px; | |
text-decoration: none; | |
display: block; | |
cursor: pointer; | |
font-size: 0.9em; | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
} | |
.custom-dropdown-content div:hover { background-color: #e9e9e9; } | |
body.theme-dark .custom-dropdown-content { background-color: #333; border-color: #555; } | |
body.theme-dark .custom-dropdown-content div { color: #f1f1f1; } | |
body.theme-dark .custom-dropdown-content div:hover { background-color: #444; } | |
/* --- Group Details - Styles ------------------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
#groupDetail_MemberList { padding: 8px; font-size: 0.9em; line-height: 1.6; background-color: #f8f9fa; } | |
#groupDetail_MemberList { background-color: #2a2a2a; } | |
/* --- Advance Search - Styles ------------------------------------------------------------------------------------------------------------------------------------------------------------------------*/ | |
.search-results-table { width: 100%; border-collapse: collapse; margin-top: 15px; table-layout: fixed; } | |
.search-results-table th, .search-results-table td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
.search-results-table th { background-color: #f2f2f2; font-weight: bold; } | |
.search-results-table tbody tr:hover { background-color: #e9e9e9; cursor: pointer; } | |
body.theme-dark .search-results-table th, body.theme-dark .search-results-table td { border-color: #444; } | |
body.theme-dark .search-results-table th { background-color: #2d2d2d; } | |
body.theme-dark .search-results-table tbody tr:hover { background-color: #444; } | |
body.theme-dark #searchToggleAdvancedBtn { color: #81d4fa; } | |
body.theme-dark #advancedSearchPanel { background-color: #2a2a2a !important; border-color: #444 !important; } | |
body.theme-dark #advancedSearchPanel > div { background-color: transparent !important; } | |
body.theme-dark #advancedSearchPanel select, body.theme-dark #advancedSearchPanel input[type="text"] { background-color: #333; border-color: #555; color: #e0e0e0; } | |
body.theme-dark #advancedSearchPanel input[type="text"]::placeholder { color: #999; opacity: 1; } | |
.sortable-header { cursor: pointer; position: relative; } | |
.sortable-header::after { content: ''; display: inline-block; margin-left: 6px; opacity: 0.5; font-size: 0.8em; } | |
.sortable-header:hover { background-color: #ddd; } | |
body.theme-dark .sortable-header:hover { background-color: #3a3a3a; } | |
.sortable-header.sort-asc::after { content: '▲'; } | |
.sortable-header.sort-desc::after { content: '▼'; } | |
</style> | |
<script> | |
var lastPsCommand = ""; | |
var currentTenantName = "Company Name"; | |
var rawGroupMemberships = []; | |
var recentSearchHistory = []; | |
var isConnecting = false; | |
var fullSearchResultSet = []; | |
var searchCurrentPage = 1; | |
var searchPageSize = 10; | |
var currentSearchString = ""; | |
var currentSearchField = "Default"; | |
var sortColumn = 'DisplayName'; | |
var sortDirection = 'asc'; | |
var currentSettings = { | |
accessMode: "read", | |
theme: "light", | |
backupPath: "", | |
selectedBrowser: "chrome", | |
baseFontSize: "13px", | |
fontFamily: "sans-serif", | |
captureScaleFactor: 0.5 | |
}; | |
function toggleConnectionButtons(disable) { | |
const buttons = [ | |
document.getElementById('graph'), | |
document.getElementById('exchange'), | |
document.getElementById('teams'), | |
document.getElementById('connectAllBtn') | |
]; | |
buttons.forEach(button => { | |
if (button) { | |
button.disabled = disable; | |
button.style.opacity = disable ? '0.6' : '1'; | |
button.style.cursor = disable ? 'not-allowed' : 'pointer'; | |
} | |
}); | |
} | |
function updateStatusMessage(message, type = 'info') { | |
// Listing of all status message SPAN IDs in all modals | |
const statusElementIds = [ | |
'dashboard_status_message', | |
'manageModalStatusMessage', | |
'editUserModalStatusMessage', | |
'manageGroupsStatusMessage', | |
'manageLicensesStatusMessage', | |
'manageAutoreplyStatusMessage', | |
'delFwdStatusMessage', | |
'teamsPhoneStatusMessage', | |
'runPsCommandStatusMessage', | |
'pickLicenseStatusMessage' | |
]; | |
const statusClasses = ['status-error', 'status-success', 'status-info']; // All possible status classes | |
statusElementIds.forEach(id => { | |
const statusElement = document.getElementById(id); | |
if (statusElement) { | |
statusElement.textContent = message; | |
statusClasses.forEach(cls => statusElement.classList.remove(cls)); | |
if (type === 'error') { | |
statusElement.classList.add('status-error'); | |
} else if (type === 'success') { | |
statusElement.classList.add('status-success'); | |
} else { | |
statusElement.classList.add('status-info'); | |
} | |
} | |
}); | |
} | |
function playNotificationSound() { | |
try { | |
const audioCtx = new (window.AudioContext || window.webkitAudioContext || window.audioContext); | |
if (!audioCtx) { | |
console.warn("AudioContext not supported, cannot play sound."); | |
return; | |
} | |
const oscillator = audioCtx.createOscillator(); | |
const gainNode = audioCtx.createGain(); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioCtx.destination); | |
oscillator.type = 'sine'; | |
oscillator.frequency.setValueAtTime(700, audioCtx.currentTime); | |
gainNode.gain.setValueAtTime(0.05, audioCtx.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime(0.00001, audioCtx.currentTime + 0.3); | |
oscillator.start(audioCtx.currentTime); | |
oscillator.stop(audioCtx.currentTime + 0.3); | |
} catch (e) { | |
console.error("Sound alert failed:", e); | |
} | |
} | |
function fetchServiceConnection(service, forModal = false) { | |
const statusMsgId = forModal ? 'manageGraphConnectStatus' : 'dashboard_status_message'; | |
const statusElement = document.getElementById(statusMsgId); | |
if(statusElement) statusElement.textContent = 'Attempting to connect to ' + service.toUpperCase() + '... Please check PS console.'; | |
return fetch('/connect/' + service) | |
.then(response => { | |
let responsePromise; | |
if (!response.ok) { | |
if (service === 'graph') { | |
responsePromise = response.json().catch(() => response.text()); | |
} else { | |
responsePromise = response.text(); | |
} | |
return responsePromise.then(errorData => { | |
let errorMsg = 'Connection error'; | |
if (service === 'graph' && typeof errorData === 'object' && errorData !== null && errorData.error) { | |
errorMsg = errorData.error; | |
} else if (typeof errorData === 'string') { | |
errorMsg = errorData; | |
} else { | |
errorMsg = 'Connection error ('+ service +') status: ' + response.status; | |
} | |
throw new Error(errorMsg.substring(0,250)); | |
}); | |
} | |
if (service === 'graph') { | |
return response.json(); | |
} | |
return response.text(); | |
}) | |
.then(data => { | |
let message = ''; | |
if (service === 'graph' && typeof data === 'object' && data !== null) { | |
message = data.status || "Successfully connected to Microsoft Graph."; | |
if (data.companyName && data.companyName.trim() !== "" && data.companyName !== "Company Name") { | |
currentTenantName = data.companyName.trim(); | |
document.getElementById('tenantHeader').textContent = currentTenantName; | |
} else if (data.companyName === "Company Name" || (data.companyName && data.companyName.trim() === "")) { | |
// If the fetched name is the default placeholder or empty, and the current header is also the placeholder, | |
// update to indicate unavailability. | |
if (document.getElementById('tenantHeader').textContent === 'Company Name') { | |
document.getElementById('tenantHeader').textContent = 'Company Name (Unavailable)'; | |
currentTenantName = 'Company Name (Unavailable)'; | |
} | |
} | |
} else { | |
message = data; | |
} | |
if(statusElement) { | |
statusElement.textContent = message; | |
statusElement.className = forModal ? '' : 'value'; | |
if (forModal) statusElement.style.color = document.body.classList.contains('theme-dark') ? '#a5d6a7' : '#28a745'; | |
else statusElement.classList.add('status-success'); | |
} | |
const button = document.getElementById(service); | |
if (button) button.style.backgroundColor = 'lightgreen'; | |
return { success: true, message: message }; | |
}) | |
.catch(error => { | |
if(statusElement) { | |
statusElement.textContent = 'Connection to ' + service.toUpperCase() + ' failed: ' + error.message; | |
statusElement.className = forModal ? '' : 'value'; | |
if (forModal) statusElement.style.color = document.body.classList.contains('theme-dark') ? '#ff8a80' : '#dc3545'; | |
else statusElement.classList.add('status-error'); | |
} | |
const button = document.getElementById(service); | |
if (button) button.style.backgroundColor = 'pink'; | |
return { success: false, message: error.message }; | |
}); | |
} | |
function handleSingleConnectClick(serviceName) { | |
const btn = document.getElementById(serviceName); | |
if (!btn) return; | |
const tenantHeaderSpan = document.getElementById('tenantHeader'); | |
// Set 'Connecting...' styles | |
btn.textContent = 'Connecting...'; | |
btn.style.backgroundColor = '#FFFFE0'; | |
btn.style.color = '#000000'; | |
btn.style.borderColor = '#DAA520'; | |
fetch('/connect/' + serviceName) | |
.then(res => { | |
if (!res.ok) { | |
return res.json().catch(() => { | |
throw new Error('Connection failed: HTTP ' + res.status + ' ' + res.statusText); | |
}).then(err => { | |
let error = new Error(err.message || err.error || 'Connection failed.'); | |
error.data = err; | |
throw error; | |
}); | |
} | |
return res.json(); | |
}) | |
.then(data => { | |
if (data.status === 'success') { | |
btn.style.backgroundColor = 'lightgreen'; | |
btn.style.borderColor = '#2E8B57'; | |
updateStatusMessage(data.message, 'success'); | |
if (data.tenant && tenantHeaderSpan) { | |
tenantHeaderSpan.textContent = data.tenant; | |
window.currentTenantName = data.tenant; | |
} | |
} else { | |
throw new Error(data.message || 'An unknown error occurred.'); | |
} | |
}) | |
.catch(error => { | |
// This block handles errors, including the "already_connected" special case for Graph | |
if (error.data && error.data.status === 'already_connected') { | |
if (confirm('You are already connected as ' + error.data.account + '.\n\nDo you want to disconnect and log in as a different user?')) { | |
const disconnectAllBtn = document.getElementById('disconnectAllBtn'); | |
if (disconnectAllBtn) disconnectAllBtn.click(); | |
} | |
btn.style.backgroundColor = 'lightgreen'; | |
btn.style.borderColor = '#2E8B57'; | |
updateStatusMessage('Already connected as ' + error.data.account + '.', 'info'); | |
} else { | |
// Standard error handling for all other failures | |
btn.style.backgroundColor = 'pink'; | |
btn.style.borderColor = '#B22222'; | |
updateStatusMessage('Connection to ' + serviceName + ' failed: ' + error.message, 'error'); | |
} | |
}) | |
.finally(() => { | |
// Revert button text after attempt is complete | |
btn.textContent = serviceName.charAt(0).toUpperCase() + serviceName.slice(1); | |
}); | |
} | |
async function connectAllServicesClick() { | |
// First, check if already connected to Graph to avoid accidental re-logins | |
try { | |
const mgContextResponse = await fetch('/status'); | |
if (mgContextResponse.ok) { | |
const mgContext = await mgContextResponse.json(); | |
if (mgContext.graph && mgContext.graph.account) { | |
if (confirm('You are already connected to Graph as ' + mgContext.graph.account + '.\n\nDo you want to disconnect all services and start a fresh connection sequence?')) { | |
if(disconnectAllBtn) { | |
disconnectAllBtn.click(); | |
await new Promise(resolve => setTimeout(resolve, 1500)); | |
} | |
} else { | |
return; | |
} | |
} | |
} | |
} catch (e) { | |
console.warn("Could not check initial status before 'Connect All', proceeding.", e); | |
} | |
if(connectAllBtn) connectAllBtn.disabled = true; | |
const services = ['graph', 'exchange', 'teams']; | |
let anyFailed = false; | |
// Define tenantHeaderSpan before the loop that needs it. | |
const tenantHeaderSpan = document.getElementById('tenantHeader'); | |
for (const service of services) { | |
const btn = document.getElementById(service); | |
if (btn) { | |
// Set 'Connecting...' styles | |
btn.textContent = 'Connecting...'; | |
btn.style.backgroundColor = '#FFFFE0'; | |
btn.style.color = '#000000'; | |
btn.style.borderColor = '#DAA520'; | |
try { | |
const response = await fetch('/connect/' + service); | |
const data = await response.json(); | |
if (data.status !== 'success') throw new Error(data.message); | |
btn.style.backgroundColor = 'lightgreen'; | |
btn.style.borderColor = '#2E8B57'; | |
// Check for tenantHeaderSpan | |
if (data.tenant && tenantHeaderSpan) { | |
tenantHeaderSpan.textContent = data.tenant; | |
} | |
updateStatusMessage("Successfully connected to " + service + ".", 'success'); | |
} catch (error) { | |
btn.style.backgroundColor = 'pink'; | |
btn.style.borderColor = '#B22222'; | |
anyFailed = true; | |
updateStatusMessage(service + ' connection failed: ' + error.message, 'error'); | |
} finally { | |
btn.textContent = service.charAt(0).toUpperCase() + service.slice(1); | |
} | |
} | |
} | |
if(connectAllBtn) { | |
connectAllBtn.style.backgroundColor = anyFailed ? 'pink' : '#ADD8E6'; | |
connectAllBtn.disabled = false; | |
} | |
} | |
// --- Event Listener for the "Connect All" Button --- | |
const connectAllBtn = document.getElementById('connectAllBtn'); | |
if (connectAllBtn) { | |
connectAllBtn.addEventListener('click', async () => { | |
// Check if already connected to Graph | |
try { | |
const mgContextResponse = await fetch('/status'); | |
if (mgContextResponse.ok) { | |
const mgContext = await mgContextResponse.json(); | |
if (mgContext.graph && mgContext.graph.account) { | |
if (confirm('You are already connected to Graph as ' + mgContext.graph.account + '.\n\nDo you want to disconnect all services and start a fresh connection sequence?')) { | |
const disconnectAllBtn = document.getElementById('disconnectAllBtn'); | |
if(disconnectAllBtn) { | |
disconnectAllBtn.click(); | |
await new Promise(resolve => setTimeout(resolve, 1500)); | |
} | |
} else { | |
return; | |
} | |
} | |
} | |
} catch (e) { | |
console.warn("Could not check initial status before 'Connect All', proceeding.", e); | |
} | |
// --- Proceed with connection sequence --- | |
connectAllBtn.disabled = true; | |
const services = ['graph', 'exchange', 'teams']; | |
let anyFailed = false; | |
// Define tenantHeaderSpan before the loop that uses it. | |
const tenantHeaderSpan = document.getElementById('tenantHeader'); | |
for (const service of services) { | |
const btn = document.getElementById(service); | |
if (btn) { | |
// Set 'Connecting...' styles | |
btn.textContent = 'Connecting...'; | |
btn.style.backgroundColor = '#FFFFE0'; | |
btn.style.color = '#000000'; | |
btn.style.borderColor = '#DAA520'; | |
try { | |
const response = await fetch('/connect/' + service); | |
const data = await response.json(); | |
if (data.status !== 'success') throw new Error(data.message); | |
// On success, set to green | |
btn.style.backgroundColor = 'lightgreen'; | |
btn.style.borderColor = '#2E8B57'; | |
// Check for tenantHeaderSpan | |
if (data.tenant && tenantHeaderSpan) { | |
tenantHeaderSpan.textContent = data.tenant; | |
} | |
updateStatusMessage("Successfully connected to " + service + ".", 'success'); | |
} catch (error) { | |
// On failure, set to pink/red | |
btn.style.backgroundColor = 'pink'; | |
btn.style.borderColor = '#B22222'; | |
anyFailed = true; | |
updateStatusMessage(service + ' connection failed: ' + error.message, 'error'); | |
} finally { | |
// Revert button text after attempt is complete | |
btn.textContent = service.charAt(0).toUpperCase() + service.slice(1); | |
} | |
} | |
} | |
// Set final color for the "Connect All" button itself | |
connectAllBtn.style.backgroundColor = anyFailed ? 'pink' : '#ADD8E6'; | |
connectAllBtn.disabled = false; | |
}); | |
} | |
function disconnectAll(forModal = false) { | |
const statusMsgId = forModal ? 'manageGraphConnectStatus' : 'dashboard_status_message'; | |
const statusElement = document.getElementById(statusMsgId); | |
if(statusElement) statusElement.textContent = "Attempting to disconnect all services..."; | |
return fetch('/disconnect') // Ensure this returns a promise | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(errData => { | |
throw new Error(errData.error || response.statusText); | |
}).catch(() => { | |
throw new Error('HTTP status ' + response.status); | |
}); | |
} | |
return response.text(); | |
}) | |
.then(text => { | |
if(statusElement) { | |
statusElement.textContent = text; | |
statusElement.className = forModal ? '' : 'value'; | |
if (forModal) statusElement.style.color = document.body.classList.contains('theme-dark') ? '#a5d6a7' : '#28a745'; | |
else statusElement.classList.add('status-success'); | |
} | |
const buttonIdsToReset = ['graph', 'exchange', 'teams', 'connectAllBtn']; | |
buttonIdsToReset.forEach(id => { | |
const button = document.getElementById(id); | |
if (button) { | |
button.style.removeProperty('background-color'); | |
button.style.removeProperty('background'); | |
} | |
}); | |
const connectAllButton = document.getElementById('connectAllBtn'); | |
if(connectAllButton) connectAllButton.style.backgroundColor = ''; | |
if (!forModal) { | |
currentTenantName = "Company Name"; | |
document.getElementById('usersearch').value = ''; | |
clearUserDetails(); | |
} | |
return {success: true, message: text}; | |
}) | |
.catch(error => { | |
if(statusElement) { | |
statusElement.textContent = 'Disconnection failed: ' + error.message; | |
statusElement.className = forModal ? '' : 'value'; | |
if (forModal) statusElement.style.color = document.body.classList.contains('theme-dark') ? '#ff8a80' : '#dc3545'; | |
else statusElement.classList.add('status-error'); | |
} | |
console.error("Disconnection failed: " + error.message); | |
return {success: false, message: error.message}; | |
}); | |
} | |
function showStatus() { | |
updateStatusMessage("Fetching connection status...", 'info'); | |
fetch('/status') | |
.then(response => { | |
if (!response.ok) { | |
throw new Error('Failed to fetch status: HTTP ' + response.status + ' ' + response.statusText); | |
} | |
return response.text(); | |
}) | |
.then(text => { | |
// Display the status in an alert box | |
alert("Connection Status:\n\n" + text); | |
updateStatusMessage("Connection status displayed.", 'success'); | |
console.log("Connection Status:\n" + text); | |
}) | |
.catch(error => { | |
const errorMessage = 'Failed to fetch status: ' + error.message; | |
updateStatusMessage(errorMessage, 'error'); | |
console.error(errorMessage); | |
alert(errorMessage); | |
}); | |
} | |
function executeSimplePromptSearch() { | |
const userSearchInput = document.getElementById("usersearch"); | |
const user = userSearchInput.value; | |
if (!user || user.trim() === "") { return; } | |
updateStatusMessage("Performing simple search for '" + user + "'...", 'info'); | |
fetch('/simple-text-search?query=' + encodeURIComponent(user)) | |
.then(res => { | |
if (!res.ok) return res.json().then(err => { throw new Error(err.error || 'Server error'); }); | |
return res.json(); | |
}) | |
.then(results => { | |
if (!Array.isArray(results) || results.length === 0) { | |
updateStatusMessage("No matches found for '" + user + "'.", 'info'); | |
return; | |
} | |
let promptMessage = "Matches found for '" + user + "':\n\n"; | |
results.forEach((userObj, index) => { | |
promptMessage += (index + 1) + ". " + userObj.displayName + " (" + userObj.upn + ")\n"; | |
}); | |
promptMessage += "\nPlease enter the number of the user to select:"; | |
const choice = prompt(promptMessage); | |
if (choice && !isNaN(choice)) { | |
const selectedIndex = parseInt(choice, 10) - 1; | |
if (selectedIndex >= 0 && selectedIndex < results.length) { | |
const selectedUserUpn = results[selectedIndex].upn; | |
userSearchInput.value = selectedUserUpn; | |
updateStatusMessage("User '" + selectedUserUpn + "' selected. Click 'Load' to fetch details.", 'info'); | |
} else { | |
updateStatusMessage("Invalid selection.", 'error'); | |
} | |
} else { | |
updateStatusMessage("Simple search cancelled.", 'info'); | |
} | |
}) | |
.catch(error => { | |
updateStatusMessage('Simple search error: ' + error.message, 'error'); | |
}); | |
} | |
// Main function to trigger a search | |
function searchUser() { | |
const searchInput = document.getElementById("usersearch"); | |
const user = searchInput.value; | |
if (!user || user.trim() === "") { | |
updateStatusMessage("Please enter a name, UPN, or email to search.", 'info'); | |
return; | |
} | |
executeUserSearch(user); | |
} | |
// The core search function that calls the backend and handles the response | |
function executeUserSearch(searchString, searchField) { | |
const modal = document.getElementById('searchModal'); | |
const spinner = document.getElementById('searchSpinner'); | |
const resultsContainer = document.getElementById('searchResultsContainer'); | |
const field = searchField || 'Default'; | |
currentSearchField = field; | |
currentSearchString = searchString; | |
searchCurrentPage = 1; | |
spinner.style.display = 'block'; | |
resultsContainer.style.display = 'none'; | |
modal.style.display = 'block'; | |
const url = '/advanced-user-search?searchString=' + encodeURIComponent(searchString) + '&searchField=' + field; | |
fetch(url) | |
.then(res => { | |
if (!res.ok) return res.json().then(err => { throw new Error(err.error || 'Server error'); }); | |
return res.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
fullSearchResultSet = data.results || []; | |
renderSearchPage(); | |
spinner.style.display = 'none'; | |
resultsContainer.style.display = 'block'; | |
}) | |
.catch(error => { | |
spinner.style.display = 'none'; | |
const tableBody = document.getElementById('searchResultsBody'); | |
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center; color: red;">' + error.message + '</td></tr>'; | |
resultsContainer.style.display = 'block'; | |
}); | |
} | |
// Render a specific "page" from the full result set | |
function renderSearchPage() { | |
const tableBody = document.getElementById('searchResultsBody'); | |
const info = document.getElementById('searchResultsInfo'); | |
const nextBtn = document.getElementById('searchNextBtn'); | |
const prevBtn = document.getElementById('searchPrevBtn'); | |
tableBody.innerHTML = ''; | |
const totalResults = fullSearchResultSet.length; | |
const colSpan = 6; | |
if (totalResults === 0) { | |
tableBody.innerHTML = '<tr><td colspan="' + colSpan + '" style="text-align:center;">No results found.</td></tr>'; | |
info.textContent = "0 results"; | |
nextBtn.disabled = true; | |
prevBtn.disabled = true; | |
return; | |
} | |
const startIndex = (searchCurrentPage - 1) * searchPageSize; | |
const endIndex = startIndex + searchPageSize; | |
const pageItems = fullSearchResultSet.slice(startIndex, endIndex); | |
pageItems.forEach(user => { | |
const row = tableBody.insertRow(); | |
const title = user.title || ''; | |
const department = user.department || ''; | |
row.innerHTML = '<td>' + user.displayName + '</td>' + | |
'<td>' + user.upn + '</td>' + | |
'<td>' + title + '</td>' + | |
'<td>' + department + '</td>' + | |
'<td>' + user.source + '</td>' + | |
'<td>' + user.enabled + '</td>'; | |
row.addEventListener('dblclick', () => { | |
document.getElementById('usersearch').value = user.upn; | |
document.getElementById('searchModal').style.display = 'none'; | |
updateStatusMessage('User "' + user.upn + '" selected. Click "Load" to fetch details.', 'info'); | |
}); | |
}); | |
info.textContent = 'Showing ' + (startIndex + 1) + '–' + (startIndex + pageItems.length) + ' of ' + totalResults; | |
prevBtn.disabled = (searchCurrentPage === 1); | |
nextBtn.disabled = (endIndex >= totalResults); | |
// --- LOGIC TO SHOW SORT INDICATORS --- | |
document.querySelectorAll('.sortable-header').forEach(th => { | |
th.classList.remove('sort-asc', 'sort-desc'); | |
}); | |
const activeHeader = document.querySelector('.sortable-header[data-sortkey="' + sortColumn + '"]'); | |
if (activeHeader) { | |
activeHeader.classList.add(sortDirection === 'asc' ? 'sort-asc' : 'sort-desc'); | |
} | |
} | |
// Column Sort - Sort the master data set and redraw the table | |
function sortAndRerender() { | |
if (!fullSearchResultSet) return; | |
// The sort comparison function | |
fullSearchResultSet.sort((a, b) => { | |
const valA = a[sortColumn] || ''; | |
const valB = b[sortColumn] || ''; | |
const comparison = valA.localeCompare(valB, undefined, { numeric: true, sensitivity: 'base' }); | |
// Flip the result for descending order | |
return sortDirection === 'asc' ? comparison : -comparison; | |
}); | |
// After sorting, always go back to the first page and re-render | |
searchCurrentPage = 1; | |
renderSearchPage(); | |
} | |
function resetSearchModal() { | |
const advancedPanel = document.getElementById('advancedSearchPanel'); | |
const searchToggleBtn = document.getElementById('searchToggleAdvancedBtn'); | |
// Clear inputs and reset filters | |
document.getElementById('advancedSearchInput').value = ''; | |
document.getElementById('searchFieldSelect').value = 'Default'; | |
// Hide advanced search panel and reset toggle text | |
if (advancedPanel) advancedPanel.style.display = 'none'; | |
if (searchToggleBtn) searchToggleBtn.textContent = 'Advanced Search ▼'; | |
// Clear results table and pagination info | |
fullSearchResultSet = []; | |
renderSearchPage(); | |
} | |
function clearUserDetails() { | |
updateStatusMessage(""); | |
const placeholders = document.querySelectorAll('#userDetailsContainer .value, #userDetailsContainer textarea, #userDetailsContainer .scrollable-list-short, #userDetailsContainer .scrollable-list-medium'); | |
placeholders.forEach(el => { | |
if (el.id === 'dashboard_status_message') { | |
} else if (el.tagName === 'TEXTAREA') { | |
el.value = ''; | |
} else if (el.classList.contains('scrollable-list-short') || el.classList.contains('scrollable-list-medium')) { | |
if (el.id === 'detail_userHasFullAccessTo') { | |
el.innerHTML = ""; | |
} else if (el.id === 'detail_directReportsList') { | |
el.innerHTML = ' '; | |
} | |
else { | |
el.innerHTML = ' '; | |
} | |
} else { | |
el.textContent = ''; | |
} | |
}); | |
document.getElementById('userNameHeader').textContent = 'Firstname Lastname'; | |
document.getElementById('tenantHeader').textContent = currentTenantName; | |
rawGroupMemberships = []; | |
document.getElementById('groupSortDropdown').value = 'az'; | |
document.getElementById('checkModeDropdown').value = 'none'; | |
clearCheckerStyles(); | |
const oofDetailsContent = document.getElementById('oof_details_content'); | |
const toggleOofBtn = document.getElementById('toggleOofDetailsBtn'); | |
if (oofDetailsContent) oofDetailsContent.style.display = 'none'; | |
if (toggleOofBtn) toggleOofBtn.textContent = 'View Details'; | |
const directReportsContent = document.getElementById('direct_reports_content'); | |
const toggleDirectReportsBtn = document.getElementById('toggleDirectReportsBtn'); | |
if(directReportsContent) directReportsContent.style.display = 'none'; | |
if(toggleDirectReportsBtn) toggleDirectReportsBtn.textContent = 'Show'; | |
if(document.getElementById('detail_directReportsStatus')) document.getElementById('detail_directReportsStatus').textContent = ''; | |
const userDevicesDiv = document.getElementById('detail_userDevices'); | |
if(userDevicesDiv) userDevicesDiv.innerHTML = " "; | |
const signInActivityDiv = document.getElementById('detail_signInActivity'); | |
if(signInActivityDiv) signInActivityDiv.innerHTML = "[Click Load...]"; | |
} | |
function populateList(elementId, items, defaultTextIfNull = '', defaultTextIfEmpty = '', itemFormatter) { | |
const listDiv = document.getElementById(elementId); | |
if (!listDiv) { console.error("Element not found for list:", elementId); return; } | |
listDiv.innerHTML = ''; | |
if (items && Array.isArray(items) && items.length > 0) { | |
items.forEach(item => { | |
const div = document.createElement('div'); | |
let textContent = item; | |
if (typeof item === 'object' && item !== null) { | |
textContent = item.toString(); | |
} | |
if (elementId === 'detail_userDevices' || elementId === 'detail_directReportsList' || elementId === 'detail_userHasFullAccessTo') { | |
div.textContent = item || defaultTextIfNull; | |
} else { | |
div.textContent = itemFormatter ? itemFormatter(textContent) : (textContent || defaultTextIfNull); | |
} | |
listDiv.appendChild(div); | |
}); | |
} else { | |
let displayDefault = defaultTextIfNull; | |
if (items && Array.isArray(items) && items.length === 0) { | |
displayDefault = defaultTextIfEmpty; | |
} | |
listDiv.textContent = displayDefault; | |
} | |
} | |
function renderGroupMemberships(sortOption) { | |
const groupsTextArea = document.getElementById('detail_groupMemberships'); | |
if (!rawGroupMemberships || rawGroupMemberships.length === 0) { | |
if (Array.isArray(rawGroupMemberships) && rawGroupMemberships.length === 1 && rawGroupMemberships[0] && rawGroupMemberships[0].DisplayName && rawGroupMemberships[0].DisplayName.startsWith("[")) { | |
groupsTextArea.value = rawGroupMemberships[0].DisplayName; | |
} else { | |
groupsTextArea.value = 'No group memberships found.'; | |
} | |
return; | |
} | |
let output = ""; | |
if (sortOption === 'az') { | |
const displayNames = rawGroupMemberships.map(g => g.DisplayName).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); | |
output = displayNames.join('\n'); | |
} else if (sortOption === 'type') { | |
const types = { | |
dynamic: { label: "**Dynamic Groups**", groups: [] }, | |
m365: { label: "**Microsoft 365 Groups**", groups: [] }, | |
mailEnabledSecurity: { label: "**Mail-enabled Security Groups**", groups: [] }, | |
security: { label: "**Security Groups**", groups: [] }, | |
distribution: { label: "**Distribution Groups**", groups: [] }, | |
other: { label: "**Other/Unclassified Groups**", groups: [] } | |
}; | |
rawGroupMemberships.forEach(group => { | |
const groupTypes = Array.isArray(group.GroupTypes) ? group.GroupTypes.map(t => t.toLowerCase()) : []; | |
if (groupTypes.includes("dynamicmembership")) { | |
types.dynamic.groups.push(group.DisplayName); | |
} else if (groupTypes.includes("unified")) { | |
types.m365.groups.push(group.DisplayName); | |
} else if (group.SecurityEnabled && group.MailEnabled) { | |
types.mailEnabledSecurity.groups.push(group.DisplayName); | |
} else if (group.SecurityEnabled) { | |
types.security.groups.push(group.DisplayName); | |
} else if (group.MailEnabled) { | |
types.distribution.groups.push(group.DisplayName); | |
} else { | |
types.other.groups.push(group.DisplayName); | |
} | |
}); | |
for (const typeKey in types) { | |
if (types[typeKey].groups.length > 0) { | |
if (output !== "") output += "\n\n"; | |
output += types[typeKey].label + "\n"; | |
output += types[typeKey].groups.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })).join('\n'); | |
} | |
} | |
if (output === "") { | |
output = "No groups to display by type (or types unclassified)."; | |
} | |
} | |
groupsTextArea.value = output; | |
} | |
function clearCheckerStyles() { | |
document.querySelectorAll('.label').forEach(el => { | |
el.classList.remove('label-correct', 'label-incorrect'); | |
}); | |
document.querySelectorAll('.info-group h3').forEach(el => { | |
el.classList.remove('title-correct', 'title-incorrect'); | |
}); | |
} | |
function runChecker(mode) { | |
clearCheckerStyles(); | |
if (mode === 'none' || !mode) return; | |
const rules = { | |
onboardingCheck: { | |
'detail_accountEnabled': { correctValue: 'yes', incorrectValue: 'no', section: 'Account Status' }, | |
'detail_isLicensed': { correctValue: 'yes', incorrectValue: 'no', section: 'Account Status' }, | |
'detail_recipientTypeDetails': { correctValue: 'usermailbox', incorrectValue: 'sharedmailbox', section: 'Mail Info' }, | |
'detail_hiddenFromAddressLists':{ correctValue: 'no', incorrectValue: 'yes', section: 'Mail Info' }, | |
'detail_oofStatus': { expected: 'disabled', incorrectValue: 'enabled', section: 'Automatic Replies' } | |
}, | |
offboardingCheck: { | |
'detail_accountEnabled': { correctValue: 'no', incorrectValue: 'yes', section: 'Account Status' }, | |
'detail_isLicensed': { correctValue: 'no', incorrectValue: 'yes', section: 'Account Status' }, | |
'detail_recipientTypeDetails': { correctValue: 'sharedmailbox', incorrectValue: 'usermailbox', section: 'Mail Info' }, | |
'detail_hiddenFromAddressLists':{ correctValue: 'yes', incorrectValue: 'no', section: 'Mail Info' }, | |
} | |
}; | |
const currentRules = rules[mode]; | |
if (!currentRules) return; | |
const sectionStates = {}; | |
for (const elementId in currentRules) { | |
const rule = currentRules[elementId]; | |
const valueElement = document.getElementById(elementId); | |
if (!valueElement) continue; | |
let labelElement = null; | |
const parentLabelValuePair = valueElement.closest('.label-value-pair, .info-group > div'); | |
if(parentLabelValuePair) { | |
labelElement = parentLabelValuePair.querySelector('.label'); | |
} | |
if (!labelElement) continue; | |
const sectionTitleElement = valueElement.closest('.info-group')?.querySelector('h3'); | |
if (!sectionTitleElement) continue; | |
const sectionName = rule.section; | |
if (!sectionStates[sectionName]) { | |
sectionStates[sectionName] = { allCorrect: true, anyIncorrect: false, element: sectionTitleElement, hasChecks: false }; | |
} | |
sectionStates[sectionName].hasChecks = true; | |
const actualValue = valueElement.textContent.trim().toLowerCase(); | |
let fieldIsMarkedCorrect = false; | |
let fieldIsMarkedIncorrect = false; | |
labelElement.classList.remove('label-correct', 'label-incorrect'); | |
if (rule.hasOwnProperty('correctValue') && rule.hasOwnProperty('incorrectValue')) { | |
if (actualValue === rule.correctValue.toLowerCase()) { | |
fieldIsMarkedCorrect = true; | |
labelElement.classList.add('label-correct'); | |
} else if (actualValue === rule.incorrectValue.toLowerCase()) { | |
fieldIsMarkedIncorrect = true; | |
labelElement.classList.add('label-incorrect'); | |
} | |
} else if (rule.hasOwnProperty('expected')) { | |
if (actualValue === rule.expected.toLowerCase()) { | |
fieldIsMarkedCorrect = true; | |
labelElement.classList.add('label-correct'); | |
} else if (rule.hasOwnProperty('incorrectValue') && actualValue === rule.incorrectValue.toLowerCase()) { | |
fieldIsMarkedIncorrect = true; | |
labelElement.classList.add('label-incorrect'); | |
} | |
} | |
if (fieldIsMarkedIncorrect) { | |
sectionStates[sectionName].anyIncorrect = true; | |
sectionStates[sectionName].allCorrect = false; | |
} else if (!fieldIsMarkedCorrect) { | |
sectionStates[sectionName].allCorrect = false; | |
} | |
} | |
for (const sectionName in sectionStates) { | |
const state = sectionStates[sectionName]; | |
state.element.classList.remove('title-correct', 'title-incorrect'); | |
if (state.hasChecks) { | |
if (state.anyIncorrect) { | |
state.element.classList.add('title-incorrect'); | |
} else if (state.allCorrect) { | |
state.element.classList.add('title-correct'); | |
} | |
} | |
} | |
} | |
function loadUserDetails() { | |
const upnInput = document.getElementById("usersearch"); | |
const upnToLoad = upnInput.value; | |
if (!upnToLoad || upnToLoad === 'Firstname Lastname' || upnToLoad.startsWith('[')) { | |
updateStatusMessage("Please search and select a user first.", 'error'); | |
return; | |
} | |
const originalSearchValue = upnInput.value; | |
clearUserDetails(); | |
upnInput.value = originalSearchValue; | |
document.getElementById('userNameHeader').textContent = 'Loading for: ' + originalSearchValue; | |
document.getElementById('detail_upn').textContent = originalSearchValue; | |
const placeholders = document.querySelectorAll('#userDetailsContainer .value, #userDetailsContainer textarea'); | |
placeholders.forEach(el => { | |
if (el.id !== 'dashboard_status_message' && el.id !== 'userNameHeader' && el.id !== 'tenantHeader' && el.id !== 'detail_upn') { | |
if (el.tagName === 'TEXTAREA') { el.value = 'Loading...';} | |
else if (el.classList.contains('scrollable-list-short') || el.classList.contains('scrollable-list-medium')) { | |
if (el.id === 'detail_userHasFullAccessTo') { el.innerHTML = ""; } | |
else { el.innerHTML = '<i>Loading...</i>'; } | |
} | |
else { el.textContent = '...'; } | |
} | |
}); | |
updateStatusMessage('Loading user details for ' + upnToLoad + '...', 'info'); | |
fetch('/getuserdetails?upn=' + encodeURIComponent(upnToLoad)) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(errData => { | |
throw new Error(errData.error || 'Failed to load user details'); | |
}).catch(() => { throw new Error('Failed to load user details: HTTP status ' + response.status + ". Check PS Console."); }); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
console.log("DEBUG JS: UserDetails received", data); | |
if (data.Error) { | |
updateStatusMessage('Error loading details: ' + data.Error, 'error'); | |
clearUserDetails(); | |
document.getElementById("usersearch").value = upnToLoad; | |
document.getElementById('userNameHeader').textContent = upnToLoad + " (Error)"; | |
document.getElementById('detail_upn').textContent = upnToLoad; | |
clearCheckerStyles(); | |
return; | |
} | |
if (data.UserPrincipalName) { | |
const upn = data.UserPrincipalName; | |
const index = recentSearchHistory.indexOf(upn); | |
if (index > -1) { | |
recentSearchHistory.splice(index, 1); | |
} | |
recentSearchHistory.unshift(upn); | |
// Limit history to the last 10 searches | |
if (recentSearchHistory.length > 10) { | |
recentSearchHistory.pop(); | |
} | |
} | |
document.getElementById('userNameHeader').textContent = (data.FirstName && data.LastName) ? (data.FirstName + ' ' + data.LastName) : (data.DisplayName || upnToLoad); | |
if (data.TenantName && data.TenantName !== "Company Name" && data.TenantName.trim() !== "") { | |
document.getElementById('tenantHeader').textContent = data.TenantName; | |
currentTenantName = data.TenantName; | |
} else { | |
document.getElementById('tenantHeader').textContent = document.getElementById('tenantHeader').textContent || currentTenantName; | |
} | |
document.getElementById('detail_upn').textContent = data.UserPrincipalName || ''; | |
document.getElementById('detail_title').textContent = data.JobTitle || ''; | |
document.getElementById('detail_department').textContent = data.Department || ''; | |
document.getElementById('detail_manager').textContent = data.ManagerDisplayName || ''; | |
document.getElementById('detail_companyName').textContent = data.CompanyName || ''; | |
document.getElementById('detail_userType').textContent = data.UserType || ''; | |
document.getElementById('detail_userId').textContent = data.Id || ''; | |
document.getElementById('detail_accountEnabled').textContent = data.AccountEnabled === true ? 'Yes' : (data.AccountEnabled === false ? 'No' : ''); | |
document.getElementById('detail_isLicensed').textContent = data.IsLicensed ? 'Yes' : 'No'; | |
populateList('detail_licenses', data.Licenses, '', '', item => item || ''); | |
document.getElementById('detail_usageLocation').textContent = data.UsageLocation || ''; | |
document.getElementById('detail_dirSynced').textContent = data.OnPremisesSyncEnabled ? 'Yes' : 'No'; | |
document.getElementById('detail_passwordLastChanged').textContent = data.PasswordLastChangedDateTime ? new Date(data.PasswordLastChangedDateTime).toLocaleString() : ''; | |
document.getElementById('detail_lastLogin').textContent = data.LastSignInDateTime ? new Date(data.LastSignInDateTime).toLocaleString() : ''; | |
document.getElementById('detail_createdDate').textContent = data.CreatedDateTime ? new Date(data.CreatedDateTime).toLocaleString() : ''; | |
document.getElementById('detail_physicalDeliveryOfficeName').textContent = data.PhysicalDeliveryOfficeName || ''; | |
document.getElementById('detail_streetAddress').textContent = data.StreetAddress || ''; | |
document.getElementById('detail_city').textContent = data.City || ''; | |
document.getElementById('detail_state').textContent = data.State || ''; | |
document.getElementById('detail_postalCode').textContent = data.PostalCode || ''; | |
document.getElementById('detail_country').textContent = data.Country || ''; | |
document.getElementById('detail_mobilePhone').textContent = data.MobilePhone || ''; | |
document.getElementById('detail_businessPhones').textContent = data.BusinessPhones || ''; | |
document.getElementById('detail_faxNumber').textContent = data.FaxNumber || ''; | |
document.getElementById('detail_mailDisplayName').textContent = data.MailDisplayName || ''; | |
document.getElementById('detail_mail').textContent = data.Mail || ''; | |
document.getElementById('detail_mailNickname').textContent = data.MailNickname || ''; | |
document.getElementById('detail_notes').textContent = data.Notes || ''; | |
document.getElementById('detail_recipientTypeDetails').textContent = data.RecipientTypeDetails || ''; | |
document.getElementById('detail_mailboxQuota').textContent = data.MailboxQuota || ''; | |
document.getElementById('detail_forwardingSmtpAddress').textContent = data.ForwardingSmtpAddress || ''; | |
document.getElementById('detail_deliverToMailboxAndForward').textContent = data.DeliverToMailboxAndForward === true ? 'Yes' : (data.DeliverToMailboxAndForward === false ? 'No' : (data.DeliverToMailboxAndForward === null ? '' : '')); | |
document.getElementById('detail_hiddenFromAddressLists').textContent = data.HiddenFromAddressLists === true ? 'Yes' : (data.HiddenFromAddressLists === false ? 'No' : ''); | |
document.getElementById('detail_archiveStatus').textContent = data.ArchiveStatus || ''; | |
populateList('detail_proxyAddresses', data.ProxyAddresses, '', '', item => item || ''); | |
populateList('detail_fullAccessDelegates', data.FullAccessDelegates, '', 'None', item => item || ''); | |
populateList('detail_sendAsDelegates', data.SendAsDelegates, '', 'None', item => item || ''); | |
populateList('detail_sendOnBehalfDelegates', data.SendOnBehalfDelegates, '', 'None', item => item || ''); | |
document.getElementById('detail_oofStatus').textContent = data.OofStatus || ''; | |
document.getElementById('detail_oofExternalAudience').textContent = data.OofExternalAudience || ''; | |
document.getElementById('detail_oofScheduled').textContent = data.OofScheduled || ''; | |
document.getElementById('detail_oofStartTime').textContent = data.OofStartTime ? new Date(data.OofStartTime).toLocaleString() : ''; | |
document.getElementById('detail_oofEndTime').textContent = data.OofEndTime ? new Date(data.OofEndTime).toLocaleString() : ''; | |
document.getElementById('detail_oofInternalReply').innerHTML = data.OofInternalReply || '<i>(No internal reply set)</i>'; | |
document.getElementById('detail_oofExternalReply').innerHTML = data.OofExternalReply || '<i>(No external reply set)</i>'; | |
document.getElementById('detail_directReportsStatus').textContent = data.HasDirectReports ? 'Yes' : 'No'; | |
populateList('detail_directReportsList', data.DirectReportsList, '', 'No direct reports found.', item => item || ''); | |
rawGroupMemberships = data.GroupMemberships || []; | |
renderGroupMemberships(document.getElementById('groupSortDropdown').value); | |
populateList('detail_mfaMethods', data.MfaMethods, '', 'None', item => item || ''); | |
document.getElementById('detail_teamsPhoneNumber').textContent = data.TeamsPhoneNumber || ''; | |
document.getElementById('detail_teamsPhoneType').textContent = data.TeamsPhoneType || ''; | |
document.getElementById('detail_teamsEnterpriseVoice').textContent = data.TeamsEnterpriseVoice === true ? 'Yes' : (data.TeamsEnterpriseVoice === false ? 'No' : (data.TeamsEnterpriseVoice === null || data.TeamsEnterpriseVoice === '' ? '' : '')); | |
document.getElementById('detail_teamsCallingPolicy').textContent = data.TeamsCallingPolicy || ''; | |
document.getElementById('detail_teamsUpgradePolicy').textContent = data.TeamsUpgradePolicy || ''; | |
document.getElementById('detail_teamsTenantDialPlan').textContent = data.TeamsTenantDialPlan || ''; | |
document.getElementById('detail_teamsOnlineVoiceRoutingPolicy').textContent = data.TeamsOnlineVoiceRoutingPolicy || ''; | |
populateList('detail_userDevices', data.UserDevices, '', 'No devices found', item => item || ''); | |
runChecker(document.getElementById('checkModeDropdown').value); | |
updateStatusMessage('User details loaded successfully for ' + upnToLoad + '.', 'success'); | |
playNotificationSound(); | |
}) | |
.catch(error => { | |
updateStatusMessage('Failed to load user details for ' + upnToLoad + ': ' + error.message, 'error'); | |
clearUserDetails(); | |
document.getElementById("usersearch").value = upnToLoad; // Restore UPN | |
document.getElementById('userNameHeader').textContent = upnToLoad + " (Load Failed)"; | |
document.getElementById('detail_upn').textContent = upnToLoad; | |
clearCheckerStyles(); | |
}); | |
} | |
function backupUserDetails() { | |
const upn = document.getElementById("usersearch").value; | |
if (!upn || upn.trim() === "" || upn === 'Firstname Lastname' || upn.startsWith('[')) { | |
updateStatusMessage("Please search and select/load a user first before backup.", 'error'); | |
return; | |
} | |
updateStatusMessage("Preparing backup for " + upn + "...", 'info'); | |
fetch('/backupuser?upn=' + encodeURIComponent(upn)) | |
.then(response => { | |
if (!response.ok) { | |
return response.text().then(text => { throw new Error(text || 'Backup request failed with status: ' + response.status); }); | |
} | |
return response.text(); | |
}) | |
.then(text => { | |
updateStatusMessage(text, 'success'); | |
console.log("Backup Status: " + text); | |
}) | |
.catch(error => { | |
updateStatusMessage("Backup failed: " + error.message, 'error'); | |
console.error("Backup failed: " + error.message); | |
}); | |
} | |
function openPortalsModal() { | |
if (!portalsModal || !portalListContainer) { | |
console.error("Portals modal elements not found."); | |
return; | |
} | |
const portals = [ | |
{ name: "365 Admin Portal", url: "https://admin.microsoft.com/Adminportal#/homepage" }, | |
{ name: "MS Entra ID (Azure AD)", url: "https://entra.microsoft.com" }, | |
{ name: "Exchange Admin Centre", url: "https://admin.exchange.microsoft.com" }, | |
{ name: "Teams Admin Centre", url: "https://admin.teams.microsoft.com" }, | |
{ name: "SharePoint Admin Centre", url: "https://admin.microsoft.com/sharepoint" }, | |
{ name: "Intune Portal (Endpoint Manager)", url: "https://intune.microsoft.com" }, | |
{ name: "MS Purview Portal (Content Search)", url: "https://purview.microsoft.com/" }, | |
{ name: "MS Defender Portal (Security & Compliance)", url: "https://security.microsoft.com/" }, | |
{ name: "Service Health (M365 Admin)", url: "https://admin.microsoft.com/Adminportal/Home#/servicehealth" }, | |
{ name: "Azure Portal (Full)", url: "https://portal.azure.com" }, | |
// Add or remove portals as needed | |
]; | |
portalListContainer.innerHTML = ''; | |
portals.forEach(portal => { | |
const portalButton = document.createElement('button'); | |
portalButton.textContent = portal.name; | |
portalButton.classList.add('portal-link-button'); | |
portalButton.title = 'Open ' + portal.name + ' (' + portal.url + ')'; | |
portalButton.addEventListener('click', () => { | |
updateStatusMessage('Opening ' + portal.name + '...', 'info'); | |
window.open(portal.url, '_blank'); | |
// Optionally close the modal after clicking a link: | |
// portalsModal.style.display = 'none'; | |
}); | |
portalListContainer.appendChild(portalButton); | |
}); | |
portalsModal.style.display = 'block'; | |
} | |
function openImageInNewTab(imageDataUrl) { | |
const newWindow = window.open(); | |
if (newWindow) { | |
newWindow.document.write('<title>User Details Capture</title><body><img src="' + imageDataUrl + '" style="image-rendering: crisp-edges; image-rendering: -moz-crisp-edges; image-rendering: -webkit-optimize-contrast; max-width:100%;"></body>'); | |
} else { | |
updateStatusMessage('Could not open new tab. Please check pop-up blocker settings.', 'error'); | |
console.error('Could not open new tab. Please check pop-up blocker settings.'); | |
} | |
} | |
async function captureUserDetails() { | |
const upn = document.getElementById("usersearch").value; | |
if (!upn || upn === 'Firstname Lastname' || upn.startsWith('[')) { | |
updateStatusMessage("Please load user details before capturing.", 'error'); | |
console.error("Please load user details before capturing."); | |
return; | |
} | |
updateStatusMessage('Capturing user details...', 'info'); | |
const captureElement = document.getElementById('captureArea'); | |
if (!captureElement) { | |
updateStatusMessage('Error: Capture area not found.', 'error'); | |
return; | |
} | |
document.body.classList.add('print-details-mode'); | |
try { | |
await document.fonts.ready; | |
} catch (e) { | |
console.warn("Error waiting for document.fonts.ready:", e); | |
} | |
html2canvas(captureElement, { | |
scale: 2, | |
useCORS: true, | |
logging: true, | |
onclone: (clonedDoc) => { | |
if (document.body.classList.contains('theme-dark')) { | |
const clonedCaptureArea = clonedDoc.getElementById('captureArea'); | |
if (clonedCaptureArea) { | |
clonedCaptureArea.style.backgroundColor = '#121212'; | |
} | |
const clonedUserNameHeader = clonedDoc.getElementById('userNameHeader'); | |
if (clonedUserNameHeader) { | |
clonedUserNameHeader.style.color = '#e0e0e0'; | |
} | |
const clonedTenantHeader = clonedDoc.getElementById('tenantHeader'); | |
if (clonedTenantHeader) { | |
clonedTenantHeader.style.color = '#b0b0b0'; | |
} | |
} | |
// Ensure scrollable lists are fully expanded for capture | |
clonedDoc.querySelectorAll('.scrollable-list-short, .scrollable-list-medium, #detail_notes.value').forEach(el => { | |
const oofParent = el.closest('#oof_details_content'); | |
const drParent = el.closest('#direct_reports_content'); | |
let isInsideCollapsedControlledSection = | |
(oofParent && oofParent.style.display === 'none') || | |
(drParent && drParent.style.display === 'none'); | |
if (!isInsideCollapsedControlledSection) { | |
el.style.maxHeight = 'none'; | |
el.style.height = 'auto'; | |
el.style.overflowY = 'visible'; | |
} | |
}); | |
// Special handling for textarea (group memberships) to render as pre for full content | |
const originalTextarea = document.getElementById('detail_groupMemberships'); | |
const cloneTextarea = clonedDoc.getElementById('detail_groupMemberships'); | |
if (originalTextarea && cloneTextarea && cloneTextarea.parentNode) { | |
const pre = clonedDoc.createElement("pre"); | |
pre.textContent = originalTextarea.value; | |
// Copy relevant styles from textarea to pre | |
const style = window.getComputedStyle(originalTextarea); | |
pre.style.font = style.font; | |
pre.style.padding = style.padding; | |
pre.style.margin = style.margin; | |
pre.style.border = style.border; | |
pre.style.width = style.width; | |
pre.style.backgroundColor = style.backgroundColor; | |
pre.style.color = style.color; | |
pre.style.boxSizing = style.boxSizing; | |
pre.style.whiteSpace = "pre-wrap"; | |
pre.style.wordWrap = "break-word"; | |
pre.style.height = 'auto'; | |
pre.style.maxHeight = 'none'; | |
pre.style.overflowY = 'hidden'; | |
cloneTextarea.parentNode.replaceChild(pre, cloneTextarea); | |
} | |
// Ensure OOF and Direct Reports content is expanded if visible | |
const oofContentElement = clonedDoc.getElementById('oof_details_content'); | |
if (oofContentElement && oofContentElement.style.display !== 'none') { | |
clonedDoc.querySelectorAll('#oof_details_content .oof-message').forEach(el => { | |
el.style.maxHeight = 'none'; | |
el.style.height = 'auto'; | |
el.style.overflowY = 'visible'; | |
}); | |
} | |
const directReportsContentElement = clonedDoc.getElementById('direct_reports_content'); | |
if (directReportsContentElement && directReportsContentElement.style.display !== 'none') { | |
const directReportsList = clonedDoc.querySelector('#direct_reports_content #detail_directReportsList'); | |
if (directReportsList) { | |
directReportsList.style.maxHeight = 'none'; | |
directReportsList.style.height = 'auto'; | |
directReportsList.style.overflowY = 'visible'; | |
} | |
} | |
} | |
}).then(canvas => { | |
canvas.style.imageRendering = "crisp-edges"; | |
canvas.style.imageRendering = "-moz-crisp-edges"; | |
canvas.style.imageRendering = "-webkit-optimize-contrast"; | |
// Downscale for better quality if original is too large (optional, adjust factor as needed) | |
const scaleFactor = parseFloat(currentSettings.captureScaleFactor); | |
if (isNaN(scaleFactor) || scaleFactor <= 0 || scaleFactor > 1) { | |
console.warn('Invalid captureScaleFactor, defaulting to 0.5. Found:', currentSettings.captureScaleFactor); | |
scaleFactor = 0.5; | |
} | |
console.log('Using screen capture downscale factor: ' + scaleFactor); | |
const downscaledCanvas = document.createElement("canvas"); | |
downscaledCanvas.width = canvas.width * scaleFactor; | |
downscaledCanvas.height = canvas.height * scaleFactor; | |
const ctx = downscaledCanvas.getContext("2d"); | |
ctx.imageSmoothingEnabled = true; | |
ctx.imageSmoothingQuality = 'high'; | |
downscaledCanvas.style.imageRendering = "crisp-edges"; | |
downscaledCanvas.style.imageRendering = "-moz-crisp-edges"; | |
downscaledCanvas.style.imageRendering = "-webkit-optimize-contrast"; | |
ctx.drawImage(canvas, 0, 0, downscaledCanvas.width, downscaledCanvas.height); | |
try { | |
const imageDataUrl = downscaledCanvas.toDataURL('image/png'); | |
if (navigator.clipboard && navigator.clipboard.write && typeof ClipboardItem !== 'undefined') { | |
downscaledCanvas.toBlob(function(blob) { | |
if (blob) { | |
navigator.clipboard.write([ | |
new ClipboardItem({ 'image/png': blob }) | |
]).then(() => { | |
updateStatusMessage('Details captured and copied to clipboard!', 'success'); | |
playNotificationSound(); | |
}).catch(err => { | |
console.error('Failed to copy image to clipboard:', err); | |
updateStatusMessage('Details captured. Failed to copy to clipboard. Opening in new tab. You can copy/save image from there.', 'error'); | |
openImageInNewTab(imageDataUrl); | |
}); | |
} else { | |
updateStatusMessage('Details captured. Failed to create blob for clipboard. Opening in new tab. You can copy/save image from there.', 'error'); | |
openImageInNewTab(imageDataUrl); | |
} | |
}, 'image/png'); | |
} else { | |
updateStatusMessage('Details captured. Clipboard API not fully supported. Opening in new tab. You can copy/save image from there.', 'info'); | |
openImageInNewTab(imageDataUrl); | |
} | |
} catch (e) { | |
console.error("Error during canvas processing or clipboard operation:", e); | |
updateStatusMessage('Error processing captured image: ' + e.message + '. Opening in new tab.', 'error'); | |
openImageInNewTab(downscaledCanvas.toDataURL('image/png')); | |
} | |
}).catch(error => { | |
console.error('Error capturing details with html2canvas:', error); | |
updateStatusMessage('Failed to capture details: ' + error.message, 'error'); | |
}).finally(() => { | |
document.body.classList.remove('print-details-mode'); | |
}); | |
} | |
function applyAccessMode(mode) { | |
const psButton = document.getElementById('runPsCommandBtn'); | |
const manageButton = document.getElementById('manageUserBtn'); | |
if (psButton) { | |
psButton.style.display = (mode === 'readwrite') ? 'inline-block' : 'none'; | |
} | |
if (manageButton) { | |
manageButton.style.display = (mode === 'readwrite') ? 'inline-block' : 'none'; | |
} | |
} | |
function applyTheme(themeName) { | |
if (themeName === 'dark') { | |
document.body.classList.add('theme-dark'); | |
} else { | |
document.body.classList.remove('theme-dark'); | |
} | |
} | |
function applyVisualSettings() { | |
document.body.style.fontSize = currentSettings.baseFontSize || "13px"; | |
let fontFamilyToApply = currentSettings.fontFamily || "sans-serif"; | |
if (fontFamilyToApply && fontFamilyToApply.toLowerCase() === 'roboto') { | |
fontFamilyToApply = '"Roboto", sans-serif'; | |
} else if (fontFamilyToApply && fontFamilyToApply.toLowerCase() === 'segoe ui') { | |
fontFamilyToApply = '"Segoe UI", sans-serif'; | |
} | |
document.body.style.fontFamily = fontFamilyToApply; | |
applyTheme(currentSettings.theme); | |
applyAccessMode(currentSettings.accessMode); | |
} | |
async function loadSettingsFromBackend() { | |
try { | |
const response = await fetch('/getsettings'); | |
if (!response.ok) { | |
throw new Error('Failed to fetch settings: ' + response.statusText); | |
} | |
const settings = await response.json(); | |
currentSettings = { ...currentSettings, ...settings }; | |
console.log("Settings loaded from backend:", currentSettings); | |
} catch (error) { | |
console.error("Error loading settings:", error); | |
updateStatusMessage("Error loading settings. Using client defaults. " + error.message, "error"); | |
} finally { | |
applyVisualSettings(); | |
// Populate settings modal fields | |
document.getElementById('accessModeRead').checked = currentSettings.accessMode === 'read'; | |
document.getElementById('accessModeReadWrite').checked = currentSettings.accessMode === 'readwrite'; | |
document.getElementById('themeLight').checked = currentSettings.theme === 'light'; | |
document.getElementById('themeDark').checked = currentSettings.theme === 'dark'; | |
document.getElementById('backupPathSetting').value = currentSettings.backupPath || ""; | |
document.getElementById('browserSetting').value = currentSettings.selectedBrowser || "chrome"; | |
document.getElementById('fontSizeSetting').value = currentSettings.baseFontSize || "13px"; | |
document.getElementById('fontFamilySetting').value = currentSettings.fontFamily || "sans-serif"; | |
document.getElementById('captureScaleSetting').value = currentSettings.captureScaleFactor || 0.5; | |
} | |
} | |
async function saveSettingsToBackend() { | |
const selectedAccessMode = document.querySelector('input[name="accessMode"]:checked').value; | |
const selectedTheme = document.querySelector('input[name="theme"]:checked').value; | |
const backupPathValue = document.getElementById('backupPathSetting').value; | |
const browserValue = document.getElementById('browserSetting').value; | |
const fontSizeValue = document.getElementById('fontSizeSetting').value; | |
const fontFamilyValue = document.getElementById('fontFamilySetting').value; | |
const captureScaleFactorValue = parseFloat(document.getElementById('captureScaleSetting').value); | |
const settingsToSave = { | |
accessMode: selectedAccessMode, | |
theme: selectedTheme, | |
backupPath: backupPathValue, | |
selectedBrowser: browserValue, | |
baseFontSize: fontSizeValue, | |
fontFamily: fontFamilyValue, | |
captureScaleFactor: captureScaleFactorValue | |
}; | |
try { | |
const response = await fetch('/savesettings', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(settingsToSave) | |
}); | |
const result = await response.json(); | |
if (!response.ok || result.error) { | |
throw new Error(result.error || 'Failed to save settings.'); | |
} | |
currentSettings = settingsToSave; | |
updateStatusMessage('Settings saved successfully! Some changes may require a script restart (e.g., Browser).', 'success'); | |
playNotificationSound(); | |
applyVisualSettings(); | |
const settingsModal = document.getElementById('settingsModal'); | |
if (settingsModal) settingsModal.style.display = 'none'; | |
} catch (error) { | |
console.error("Error saving settings:", error); | |
updateStatusMessage('Error saving settings: ' + error.message, 'error'); | |
console.error('Error saving settings: ' + error.message); | |
} | |
} | |
// --- Manage Modal Specific Functions --- | |
async function ensureGraphReadWriteConnection() { | |
const statusSpan = document.getElementById('manageGraphConnectStatus'); | |
if (!statusSpan) return; | |
if (currentSettings.accessMode !== 'readwrite') { | |
statusSpan.textContent = "Error: Set Access Mode to 'Read & Write' in main Settings first."; | |
statusSpan.style.color = document.body.classList.contains('theme-dark') ? '#ff8a80' : '#dc3545'; // Red | |
return; | |
} | |
statusSpan.textContent = "Attempting to ensure Graph ReadWrite connection..."; | |
statusSpan.style.color = document.body.classList.contains('theme-dark') ? '#aaa' : '#6c757d'; // Neutral | |
// Disconnect all services first | |
// A more targeted graph disconnect could be implemented if needed | |
const disconnectResult = await disconnectAll(true); | |
if (disconnectResult && disconnectResult.success) { | |
// Wait a brief moment before attempting to reconnect | |
await new Promise(resolve => setTimeout(resolve, 500)); | |
const connectResult = await fetchServiceConnection('graph', true); // true indicates call is from modal | |
if(connectResult && connectResult.success){ | |
// Check main graph button and update its color | |
const mainGraphButton = document.getElementById('graph'); | |
if(mainGraphButton) mainGraphButton.style.backgroundColor = 'lightgreen'; | |
} else { | |
const mainGraphButton = document.getElementById('graph'); | |
if(mainGraphButton) mainGraphButton.style.backgroundColor = 'pink'; | |
} | |
} else { | |
statusSpan.textContent = "Failed to disconnect services before reconnecting Graph. " + (disconnectResult.message || ""); | |
statusSpan.style.color = document.body.classList.contains('theme-dark') ? '#ff8a80' : '#dc3545'; // Red | |
} | |
} | |
function toggleManageCategory(category) { | |
const buttons = { | |
general: document.getElementById('manageCatGeneralBtn'), | |
onboarding: document.getElementById('manageCatOnboardingBtn'), | |
offboarding: document.getElementById('manageCatOffboardingBtn') | |
}; | |
const contents = { | |
general: document.getElementById('manageCatGeneralContent'), | |
onboarding: document.getElementById('manageCatOnboardingContent'), | |
offboarding: document.getElementById('manageCatOffboardingContent') | |
}; | |
for (const catKey in contents) { | |
if (contents[catKey]) { | |
if (catKey === category) { | |
contents[catKey].classList.toggle('active'); | |
buttons[catKey].classList.toggle('active-category', contents[catKey].classList.contains('active')); | |
} else { | |
contents[catKey].classList.remove('active'); | |
buttons[catKey].classList.remove('active-category'); | |
} | |
} | |
} | |
} | |
function generateAndCopyPassword(length, displayElementId = 'generatedPassword') { | |
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"; | |
let password = ""; | |
for (let i = 0, n = charset.length; i < length; ++i) { | |
password += charset.charAt(Math.floor(Math.random() * n)); | |
} | |
const passwordDisplay = document.getElementById(displayElementId); | |
if (passwordDisplay) { | |
if(passwordDisplay.tagName === 'INPUT'){ | |
passwordDisplay.value = password; | |
} else { | |
passwordDisplay.textContent = password; | |
} | |
navigator.clipboard.writeText(password).then(() => { | |
updateStatusMessage('Password (length ' + length + ') generated and copied to clipboard!', 'success'); | |
playNotificationSound(); | |
}).catch(err => { | |
updateStatusMessage('Password generated. Failed to copy: ' + err, 'error'); | |
}); | |
} | |
} | |
document.addEventListener('DOMContentLoaded', (event) => { | |
loadSettingsFromBackend(); | |
const settingsBtn = document.getElementById('settingsBtn'); | |
const settingsModal = document.getElementById('settingsModal'); | |
const closeSettingsModalBtn = document.getElementById('closeSettingsModalBtn'); | |
const saveSettingsBtn = document.getElementById('saveSettingsBtn'); | |
const portalsModal = document.getElementById('portalsModal'); | |
const portalListContainer = document.getElementById('portalListContainer'); | |
// --- Manage Modal Elements & Listeners --- | |
const manageUserBtn = document.getElementById('manageUserBtn'); | |
const manageModal = document.getElementById('manageModal'); | |
const closeManageModalBtn = document.getElementById('closeManageModalBtn'); | |
const manageModalUserUpnSpan = document.getElementById('manageModalUserUpn'); | |
const manageConnectGraphRWBtn = document.getElementById('manageConnectGraphRWBtn'); | |
const manageCatGeneralBtn = document.getElementById('manageCatGeneralBtn'); | |
const manageCatOnboardingBtn = document.getElementById('manageCatOnboardingBtn'); | |
const manageCatOffboardingBtn = document.getElementById('manageCatOffboardingBtn'); | |
// Password Generator Buttons (General Tab) | |
const genPass8Btn = document.getElementById('genPass8'); | |
const genPass12Btn = document.getElementById('genPass12'); | |
const genPass16Btn = document.getElementById('genPass16'); | |
const copyGeneratedPassBtn = document.getElementById('copyGeneratedPass'); | |
if(genPass8Btn) genPass8Btn.addEventListener('click', () => generateAndCopyPassword(8, 'generatedPassword')); | |
if(genPass12Btn) genPass12Btn.addEventListener('click', () => generateAndCopyPassword(12, 'generatedPassword')); | |
if(genPass16Btn) genPass16Btn.addEventListener('click', () => generateAndCopyPassword(16, 'generatedPassword')); | |
if(copyGeneratedPassBtn) { | |
copyGeneratedPassBtn.addEventListener('click', () => { | |
const passwordDisplay = document.getElementById('generatedPassword'); | |
if (passwordDisplay && passwordDisplay.textContent && passwordDisplay.textContent.trim() !== '' && passwordDisplay.textContent !== String.fromCharCode(160) /* */) { | |
navigator.clipboard.writeText(passwordDisplay.textContent).then(() => { | |
updateStatusMessage('Password copied to clipboard!', 'success'); | |
playNotificationSound(); | |
}).catch(err => { | |
updateStatusMessage('Failed to copy password: ' + err, 'error'); | |
}); | |
} else { | |
updateStatusMessage('No password generated to copy.', 'info'); | |
} | |
}); | |
} | |
// Password Generator Buttons (Onboarding Tab) | |
const onboardGenPass8Btn = document.getElementById('onboardGenPass8Btn'); | |
const onboardGenPass12Btn = document.getElementById('onboardGenPass12Btn'); | |
const onboardGenPass16Btn = document.getElementById('onboardGenPass16Btn'); | |
if(onboardGenPass8Btn) onboardGenPass8Btn.addEventListener('click', () => generateAndCopyPassword(8, 'onboardPassword')); | |
if(onboardGenPass12Btn) onboardGenPass12Btn.addEventListener('click', () => generateAndCopyPassword(12, 'onboardPassword')); | |
if(onboardGenPass16Btn) onboardGenPass16Btn.addEventListener('click', () => generateAndCopyPassword(16, 'onboardPassword')); | |
if(settingsBtn && settingsModal) { | |
settingsBtn.addEventListener('click', () => { | |
// Re-populate modal from currentSettings every time it's opened | |
document.getElementById('accessModeRead').checked = currentSettings.accessMode === 'read'; | |
document.getElementById('accessModeReadWrite').checked = currentSettings.accessMode === 'readwrite'; | |
document.getElementById('themeLight').checked = currentSettings.theme === 'light'; | |
document.getElementById('themeDark').checked = currentSettings.theme === 'dark'; | |
document.getElementById('backupPathSetting').value = currentSettings.backupPath || ""; | |
document.getElementById('browserSetting').value = currentSettings.selectedBrowser || "chrome"; | |
document.getElementById('fontSizeSetting').value = currentSettings.baseFontSize || "13px"; | |
document.getElementById('fontFamilySetting').value = currentSettings.fontFamily || "sans-serif"; | |
settingsModal.style.display = 'block'; | |
}); | |
} | |
if(closeSettingsModalBtn && settingsModal) { | |
closeSettingsModalBtn.addEventListener('click', () => { | |
settingsModal.style.display = 'none'; | |
}); | |
} | |
if(saveSettingsBtn) { | |
saveSettingsBtn.addEventListener('click', saveSettingsToBackend); | |
} | |
// Listener to open the main "Manage User" modal | |
if (manageUserBtn && manageModal) { | |
manageUserBtn.addEventListener('click', () => { | |
const currentUserUpn = document.getElementById('usersearch').value; | |
const upnDisplaySpan = document.getElementById('manageModalUserUpn'); | |
const syncStatusHint = document.getElementById('editUserSyncStatusHint'); | |
// Reset the hint each time the modal opens | |
if(syncStatusHint) { | |
syncStatusHint.style.display = 'none'; | |
syncStatusHint.textContent = ''; | |
syncStatusHint.title = ''; | |
syncStatusHint.className = 'sync-status-badge'; | |
} | |
if (!currentUserUpn || currentUserUpn.trim() === "" || currentUserUpn.startsWith('[No User')) { | |
if(upnDisplaySpan) upnDisplaySpan.textContent = "[No User Loaded]"; | |
} else { | |
if(upnDisplaySpan) upnDisplaySpan.textContent = currentUserUpn; | |
// Fetch the sync status for the loaded user | |
fetch('/getsyncstatus?upn=' + encodeURIComponent(currentUserUpn)) | |
.then(res => { | |
if (!res.ok) throw new Error('Failed to fetch sync status'); | |
return res.json(); | |
}) | |
.then(data => { | |
if (data.error) { | |
console.warn('Could not determine sync status:', data.error); | |
if(syncStatusHint) syncStatusHint.style.display = 'none'; | |
return; | |
} | |
if (syncStatusHint) { | |
if (data.onPremisesSyncEnabled === true) { | |
syncStatusHint.textContent = 'Synced User'; | |
syncStatusHint.classList.add('synced'); | |
syncStatusHint.title = 'This user is synchronized from an on-premises Active Directory. Most properties must be edited there.'; | |
} else { | |
syncStatusHint.textContent = 'Cloud User'; | |
syncStatusHint.classList.add('cloud'); | |
syncStatusHint.title = 'This user is managed directly in the cloud. Properties can be edited here.'; | |
} | |
syncStatusHint.style.display = 'inline-block'; | |
} | |
}) | |
.catch(error => { | |
console.error('Error fetching sync status:', error); | |
if(syncStatusHint) syncStatusHint.style.display = 'none'; | |
}); | |
} | |
// Ensure other parts of the modal are reset as needed before showing | |
['general', 'onboarding', 'offboarding'].forEach(cat => { | |
document.getElementById('manageCat' + cat.charAt(0).toUpperCase() + cat.slice(1) + 'Content')?.classList.remove('active'); | |
document.getElementById('manageCat' + cat.charAt(0).toUpperCase() + cat.slice(1) + 'Btn')?.classList.remove('active-category'); | |
}); | |
document.getElementById('manageCatGeneralContent').classList.add('active'); | |
document.getElementById('manageCatGeneralBtn').classList.add('active-category'); | |
manageModal.style.display = 'block'; | |
}); | |
} | |
if (closeManageModalBtn && manageModal) { | |
closeManageModalBtn.addEventListener('click', () => { | |
manageModal.style.display = 'none'; | |
}); | |
} | |
if (manageConnectGraphRWBtn) { | |
manageConnectGraphRWBtn.addEventListener('click', ensureGraphReadWriteConnection); | |
} | |
if (manageCatGeneralBtn) manageCatGeneralBtn.addEventListener('click', () => toggleManageCategory('general')); | |
if (manageCatOnboardingBtn) manageCatOnboardingBtn.addEventListener('click', () => toggleManageCategory('onboarding')); | |
if (manageCatOffboardingBtn) manageCatOffboardingBtn.addEventListener('click', () => toggleManageCategory('offboarding')); | |
window.addEventListener('click', (event) => { | |
// Close modal if outside region is clicked | |
const modals = [ | |
document.getElementById('settingsModal'), | |
document.getElementById('manageModal'), | |
document.getElementById('editUserDetailsModal'), | |
document.getElementById('manageGroupsModal'), | |
document.getElementById('manageLicensesModal'), | |
document.getElementById('manageAutoreplyModal'), | |
document.getElementById('pickLicenseSubModal'), | |
document.getElementById('manageDelegationForwardingModal'), | |
document.getElementById('manageTeamsPhoneModal'), | |
document.getElementById('portalsModal'), | |
document.getElementById('runPsCommandModal') | |
]; | |
modals.forEach(modal => { | |
if (modal && event.target == modal) { | |
modal.style.display = 'none'; | |
} | |
}); | |
}); | |
const toggleOofBtn = document.getElementById('toggleOofDetailsBtn'); | |
const oofDetailsContent = document.getElementById('oof_details_content'); | |
if (toggleOofBtn && oofDetailsContent) { | |
toggleOofBtn.addEventListener('click', function() { | |
if (oofDetailsContent.style.display === 'none' || oofDetailsContent.style.display === '') { | |
oofDetailsContent.style.display = 'block'; | |
toggleOofBtn.textContent = 'Hide Details'; | |
} else { | |
oofDetailsContent.style.display = 'none'; | |
toggleOofBtn.textContent = 'View Details'; | |
} | |
}); | |
} | |
const toggleDirectReportsBtn = document.getElementById('toggleDirectReportsBtn'); | |
const directReportsContent = document.getElementById('direct_reports_content'); | |
if (toggleDirectReportsBtn && directReportsContent) { | |
toggleDirectReportsBtn.addEventListener('click', function() { | |
if (directReportsContent.style.display === 'none' || directReportsContent.style.display === '') { | |
directReportsContent.style.display = 'block'; | |
toggleDirectReportsBtn.textContent = 'Hide'; | |
} else { | |
directReportsContent.style.display = 'none'; | |
toggleDirectReportsBtn.textContent = 'Show'; | |
} | |
}); | |
} | |
const checkModeDropdown = document.getElementById('checkModeDropdown'); | |
if(checkModeDropdown) { | |
checkModeDropdown.addEventListener('change', (e) => { | |
runChecker(e.target.value); | |
}); | |
} | |
const loadUserBtn = document.getElementById('loadUserBtn'); | |
if(loadUserBtn) { loadUserBtn.onclick = loadUserDetails; } | |
const backupBtn = document.getElementById('backupUserDetailsBtn'); | |
if(backupBtn){ backupBtn.onclick = backupUserDetails; } | |
const runPsCmdBtn = document.getElementById('runPsCommandBtn'); | |
const clearAllBtn = document.getElementById('clearAllBtn'); | |
if(clearAllBtn){ clearAllBtn.onclick = clearUserDetails; } | |
const userSearchInputForEnter = document.getElementById('usersearch'); | |
if (userSearchInputForEnter) { | |
userSearchInputForEnter.addEventListener('keypress', function(e) { | |
if (e.key === 'Enter') { | |
e.preventDefault(); | |
// "Enter" keypress event | |
executeSimplePromptSearch(); | |
} | |
}); | |
} | |
const mainSearchBtn = document.getElementById('mainSearchBtn'); | |
if (mainSearchBtn) { | |
mainSearchBtn.addEventListener('click', () => { | |
const searchInput = document.getElementById("usersearch"); | |
const user = searchInput.value; | |
resetSearchModal(); // Reset search modal on every launch | |
if (user && user.trim() !== "") { | |
// If there is text in search box, open modal AND search | |
document.getElementById('advancedSearchInput').value = user; | |
executeUserSearch(user, 'Default'); | |
} else { | |
// If there is no text, just open the blank, reset modal | |
document.getElementById('searchModal').style.display = 'block'; | |
} | |
}); | |
} | |
const searchInput = document.getElementById('usersearch'); | |
const historyDropdown = document.getElementById('searchHistoryDropdown'); | |
const showSearchHistory = () => { | |
if (!historyDropdown || recentSearchHistory.length === 0) { | |
return; | |
} | |
historyDropdown.innerHTML = ''; | |
recentSearchHistory.forEach(upn => { | |
const item = document.createElement('div'); | |
item.textContent = upn; | |
item.title = 'Load details for ' + upn; | |
// Use 'mousedown' to trigger before 'blur' hides the dropdown | |
item.addEventListener('mousedown', (e) => { | |
e.preventDefault(); // Prevent input from losing focus | |
if (searchInput) { | |
searchInput.value = upn; | |
} | |
historyDropdown.style.display = 'none'; | |
loadUserDetails(); // Automatically load the user | |
}); | |
historyDropdown.appendChild(item); | |
}); | |
historyDropdown.style.display = 'block'; | |
}; | |
const hideSearchHistory = () => { | |
// Delay hiding to allow click events on dropdown items to register | |
setTimeout(() => { | |
if (historyDropdown) { | |
historyDropdown.style.display = 'none'; | |
} | |
}, 200); | |
}; | |
if (searchInput) { | |
searchInput.addEventListener('focus', showSearchHistory); | |
searchInput.addEventListener('blur', hideSearchHistory); | |
} | |
const connectAllBtn = document.getElementById('connectAllBtn'); | |
if(connectAllBtn) { connectAllBtn.onclick = connectAllServicesClick; } | |
const copyGroupsBtn = document.getElementById('copyGroupMembershipsBtn'); | |
if(copyGroupsBtn){ | |
copyGroupsBtn.addEventListener('click', function() { | |
const groupsTextArea = document.getElementById('detail_groupMemberships'); | |
if (groupsTextArea && groupsTextArea.value) { | |
navigator.clipboard.writeText(groupsTextArea.value) | |
.then(() => { | |
updateStatusMessage('Group memberships copied to clipboard!', 'success'); | |
playNotificationSound(); | |
}) | |
.catch(err => { | |
updateStatusMessage('Failed to copy groups: ' + err.message, 'error'); | |
console.error('Failed to copy groups: ', err); | |
}); | |
} else { | |
updateStatusMessage('No group memberships to copy.', 'info'); | |
} | |
}); | |
} | |
const groupSortDropdown = document.getElementById('groupSortDropdown'); | |
if (groupSortDropdown) { | |
groupSortDropdown.addEventListener('change', (e) => renderGroupMemberships(e.target.value)); | |
} | |
const openPortalsBtn = document.getElementById('openPortalBtn'); | |
if (openPortalsBtn) { | |
openPortalsBtn.addEventListener('click', openPortalsModal); | |
} | |
if (closePortalsModalBtn) { | |
closePortalsModalBtn.addEventListener('click', () => { | |
if (portalsModal) portalsModal.style.display = 'none'; | |
}); | |
} | |
const captureBtn = document.getElementById('captureDetailsBtn'); | |
if(captureBtn) { | |
captureBtn.addEventListener('click', captureUserDetails); | |
} | |
const loadSharedAccessBtn = document.getElementById('loadSharedMailboxAccessBtn'); | |
if (loadSharedAccessBtn) { | |
loadSharedAccessBtn.addEventListener('click', function() { | |
const upn = document.getElementById("usersearch").value; | |
if (!upn || upn === 'Firstname Lastname' || upn.startsWith('[')) { | |
updateStatusMessage("Please search and select/load a user first.", 'error'); | |
return; | |
} | |
updateStatusMessage('Loading shared mailboxes ' + upn + ' has access to... This may take some time.', 'info'); | |
const sharedAccessDiv = document.getElementById('detail_userHasFullAccessTo'); | |
if (sharedAccessDiv) sharedAccessDiv.innerHTML = '<i>Loading... (This can take a while)</i>'; | |
fetch('/getsharedmailboxaccess?upn=' + encodeURIComponent(upn)) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(errData => { throw new Error(errData.error || 'Failed to load shared access'); }); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
console.log("DEBUG JS: SharedMailboxAccess data received", data); | |
populateList('detail_userHasFullAccessTo', data.AccessibleMailboxes, '', 'No shared mailbox access found.'); | |
updateStatusMessage('Shared mailbox access loaded for ' + upn + '.', 'success'); | |
}) | |
.catch(error => { | |
updateStatusMessage('Failed to load shared access: ' + error.message, 'error'); | |
if (sharedAccessDiv) sharedAccessDiv.innerHTML = ''; // Make blank on error | |
}); | |
}); | |
} | |
// --- Show/Hide Password Toggle Logic --- | |
const showPasswordCheckbox = document.getElementById('showPasswordCheckbox'); | |
if (showPasswordCheckbox) { | |
showPasswordCheckbox.addEventListener('change', () => { | |
const newPasswordInput = document.getElementById('newPasswordInput'); | |
if (newPasswordInput) { | |
// If checkbox is checked, change input type to 'text' to show the password. | |
// Otherwise, change it back to 'password' type to hide it. | |
newPasswordInput.type = showPasswordCheckbox.checked ? 'text' : 'password'; | |
} | |
}); | |
} | |
// --- Inline Reset Password Logic --- | |
const submitResetPasswordBtn = document.getElementById('submitResetPasswordBtn'); | |
if (submitResetPasswordBtn) { | |
submitResetPasswordBtn.addEventListener('click', () => { | |
const currentUserUpn = document.getElementById('manageModalUserUpn').textContent; | |
const newPasswordInput = document.getElementById('newPasswordInput'); | |
const newPassword = newPasswordInput.value; | |
if (!currentUserUpn || currentUserUpn.startsWith('[')) { | |
updateStatusMessage("Please load a user before resetting a password.", 'error'); | |
return; | |
} | |
if (newPassword.length < 8) { | |
updateStatusMessage("Password must be at least 8 characters long.", 'error'); | |
return; | |
} | |
const payload = { | |
upn: currentUserUpn, | |
newPassword: newPassword, | |
forceChange: document.getElementById('resetForceChangePasswordChk').checked | |
}; | |
if (confirm("Are you sure you want to reset the password for " + currentUserUpn + "?")) { | |
updateStatusMessage("Resetting password for " + currentUserUpn + "...", 'info'); | |
fetch('/resetpassword', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(err => { throw new Error(err.error || 'An unknown error occurred.'); }); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
if(newPasswordInput) newPasswordInput.value = ''; // Clear password field on success | |
}) | |
.catch(error => { | |
updateStatusMessage('Password reset failed: ' + error.message, 'error'); | |
}); | |
} | |
}); | |
} | |
// --- Enable/Disable Account and Revoke MFA Logic --- | |
const manageToggleAccountBtn = document.getElementById('manageToggleAccountBtn'); | |
const manageRevokeMfaBtn = document.getElementById('manageRevokeMfaBtn'); | |
if (manageToggleAccountBtn) { | |
manageToggleAccountBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
if (!upn || upn.startsWith('[')) { | |
updateStatusMessage("Please load a user first.", 'error'); | |
return; | |
} | |
// Get the current status of the account | |
updateStatusMessage("Fetching current account status...", 'info'); | |
fetch('/getaccountstatus?upn=' + encodeURIComponent(upn)) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(err => { throw new Error(err.error); }); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
const isCurrentlyEnabled = data.enabled; | |
const actionVerb = isCurrentlyEnabled ? "DISABLE" : "ENABLE"; | |
const oppositeState = !isCurrentlyEnabled; | |
if (confirm('This account is currently ' + (isCurrentlyEnabled ? 'ENABLED' : 'DISABLED') + '. Are you sure you want to ' + actionVerb + ' it?')) { | |
updateStatusMessage(actionVerb.slice(0, 1) + actionVerb.slice(1).toLowerCase() + 'ing account...', 'info'); | |
const payload = { upn: upn, enabled: oppositeState }; | |
return fetch('/setaccountstatus', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
} | |
return Promise.reject('cancelled'); // Stop the promise chain | |
}) | |
.then(response => { | |
if (!response) return; // Cancel | |
if (!response.ok) { | |
return response.json().then(err => { throw new Error(err.error); }); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
if (!data) return; // Cancel | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
// Optionally reload user details in the main dashboard to reflect the change | |
if (document.getElementById('loadUserBtn')) document.getElementById('loadUserBtn').click(); | |
}) | |
.catch(error => { | |
if (error !== 'cancelled') { | |
updateStatusMessage('Action failed: ' + error.message, 'error'); | |
} else { | |
updateStatusMessage('Action cancelled.', 'info'); | |
} | |
}); | |
}); | |
} | |
if (manageRevokeMfaBtn) { | |
manageRevokeMfaBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
if (!upn || upn.startsWith('[')) { | |
updateStatusMessage("Please load a user first.", 'error'); | |
return; | |
} | |
if (confirm('Are you sure you want to revoke all MFA sessions for ' + upn + '? This forces the user to re-authenticate on all devices.')) { | |
updateStatusMessage('Revoking MFA sessions for ' + upn + '...', 'info'); | |
const payload = { upn: upn }; | |
fetch('/revokemfa', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(err => { throw new Error(err.error); }); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
}) | |
.catch(error => { | |
updateStatusMessage('Revoke MFA failed: ' + error.message, 'error'); | |
}); | |
} else { | |
updateStatusMessage('Revoke MFA cancelled.', 'info'); | |
} | |
}); | |
} | |
// --- COMPLETE Edit User Details Modal Logic --- | |
const fieldMap = { | |
'edit_JobTitle': 'JobTitle', | |
'edit_Department': 'Department', | |
'edit_CompanyName': 'CompanyName', | |
'edit_OfficeLocation': 'PhysicalDeliveryOfficeName', | |
'edit_StreetAddress': 'StreetAddress', | |
'edit_City': 'City', | |
'edit_State': 'State', | |
'edit_PostalCode': 'PostalCode', | |
'edit_Country': 'Country', | |
'edit_MobilePhone': 'MobilePhone', | |
'edit_BusinessPhones': 'BusinessPhones', | |
'edit_FaxNumber': 'FaxNumber', | |
'edit_Notes': 'Notes' | |
}; | |
const editUserDetailsModal = document.getElementById('editUserDetailsModal'); | |
const manageEditUserDetailsBtn = document.getElementById('manageEditUserDetailsBtn'); | |
const closeEditUserModalBtn = document.getElementById('closeEditUserModalBtn'); | |
const saveUserDetailsBtn = document.getElementById('saveUserDetailsBtn'); | |
const editPickManagerBtn = document.getElementById('editPickManagerBtn'); | |
const editClearManagerBtn = document.getElementById('editClearManagerBtn'); | |
// Listener to OPEN the Edit User Details modal | |
if (manageEditUserDetailsBtn) { | |
manageEditUserDetailsBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
const saveButton = document.getElementById('saveUserDetailsBtn'); | |
const statusContainer = document.getElementById('editUserModalStatusContainer'); | |
const statusMessage = document.getElementById('editUserModalStatusMessage'); | |
statusContainer.style.display = 'none'; | |
saveButton.disabled = true; | |
if (upn && !upn.startsWith('[')) { | |
statusContainer.style.display = 'block'; | |
statusMessage.textContent = 'Loading current user details...'; | |
const userDetailsPromise = fetch('/getuserdetails?upn=' + encodeURIComponent(upn)).then(res => res.json()); | |
const domainsPromise = fetch('/gettenantdomains').then(res => res.json()); | |
Promise.all([userDetailsPromise, domainsPromise]) | |
.then(([userData, domainData]) => { | |
if (userData.Error) throw new Error(userData.Error); | |
if (domainData.error) throw new Error(domainData.error); | |
document.getElementById('editUserModalUpn').textContent = userData.UserPrincipalName; | |
for (const inputId in fieldMap) { | |
const dataKey = fieldMap[inputId]; | |
document.getElementById(inputId).value = userData[dataKey] || ''; | |
document.getElementById('current_' + dataKey).textContent = userData[dataKey] || '[blank]'; | |
} | |
const [userPart, domainPart] = (userData.UserPrincipalName || '').split('@'); | |
const userInput = document.getElementById('edit_UserPrincipalName_User'); | |
const domainSelect = document.getElementById('edit_UserPrincipalName_Domain'); | |
userInput.value = userPart; | |
domainSelect.innerHTML = ''; | |
domainData.domains.forEach(domain => { | |
const option = document.createElement('option'); | |
option.value = domain; | |
option.textContent = domain; | |
if (domain.toLowerCase() === (domainPart || '').toLowerCase()) { | |
option.selected = true; | |
} | |
domainSelect.appendChild(option); | |
}); | |
document.getElementById('current_UserPrincipalName').textContent = userData.UserPrincipalName; | |
document.getElementById('edit_Manager').value = userData.ManagerDisplayName || ''; | |
document.getElementById('current_ManagerDisplayName').textContent = userData.ManagerDisplayName || '[blank]'; | |
document.getElementById('edit_DisplayName').value = userData.DisplayName || ''; | |
document.getElementById('current_DisplayName').textContent = userData.DisplayName || '[blank]'; | |
document.getElementById('edit_GivenName').value = userData.FirstName || ''; | |
document.getElementById('current_GivenName').textContent = userData.FirstName || '[blank]'; | |
document.getElementById('edit_Surname').value = userData.LastName || ''; | |
document.getElementById('current_Surname').textContent = userData.LastName || '[blank]'; | |
document.getElementById('edit_UsageLocation').value = userData.UsageLocation || ''; | |
document.getElementById('current_UsageLocation').textContent = userData.UsageLocation || '[Not Set]'; | |
statusContainer.style.display = 'none'; | |
saveButton.disabled = false; | |
editUserDetailsModal.style.display = 'block'; | |
}).catch(error => { | |
statusMessage.textContent = 'Failed to load details: ' + error.message; | |
}); | |
} else { | |
document.getElementById('editUserModalUpn').textContent = '[No User Loaded]'; | |
document.querySelectorAll('.edit-user-table input[type="text"], .edit-user-table textarea').forEach(el => el.value = ''); | |
document.querySelectorAll('.edit-user-table .field-current-value').forEach(el => el.textContent = '[No User Loaded]'); | |
const domainSelect = document.getElementById('edit_UserPrincipalName_Domain'); | |
domainSelect.innerHTML = '<option>...</option>'; | |
fetch('/gettenantdomains').then(res => res.json()).then(domainData => { | |
if (domainData.error) throw new Error(domainData.error); | |
domainSelect.innerHTML = ''; | |
domainData.domains.forEach(domain => { | |
const option = document.createElement('option'); | |
option.value = domain; | |
option.textContent = domain; | |
domainSelect.appendChild(option); | |
}); | |
}).catch(error => { | |
statusContainer.style.display = 'block'; | |
statusMessage.textContent = 'Could not load domains: ' + error.message; | |
}); | |
editUserDetailsModal.style.display = 'block'; | |
} | |
}); | |
} | |
// Listener for the Manager PICKER button | |
if (editPickManagerBtn) { | |
editPickManagerBtn.addEventListener('click', () => { | |
const query = prompt("Search for a manager (name or UPN):"); | |
if (!query) return; | |
updateStatusMessage('Searching for manager...', 'info'); | |
fetch('/search?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
if (results.error) throw new Error(results.error); | |
if (results.length === 0) { | |
updateStatusMessage('No managers found for that query.', 'info'); | |
return; | |
} | |
let promptMessage = 'Select a manager: \n'; | |
results.forEach((u, i) => { | |
promptMessage += (i + 1) + '. ' + u.displayName + ' (' + u.upn + ')\n'; | |
}); | |
const choice = parseInt(prompt(promptMessage), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedManager = results[choice - 1]; | |
document.getElementById('edit_Manager').value = selectedManager.upn; | |
updateStatusMessage('Manager selected.', 'success'); | |
} | |
}) | |
.catch(error => updateStatusMessage('Manager search failed: ' + error.message, 'error')); | |
}); | |
} | |
// Listener to CLEAR manager button | |
if (editClearManagerBtn) { | |
editClearManagerBtn.addEventListener('click', () => { | |
document.getElementById('edit_Manager').value = ''; | |
}); | |
} | |
// Listener to SAVE changes | |
if (saveUserDetailsBtn) { | |
saveUserDetailsBtn.addEventListener('click', () => { | |
console.log("DEBUG EDIT USER: 'Save Changes' button clicked."); | |
const originalUpn = document.getElementById('editUserModalUpn').textContent; | |
const changes = {}; | |
const confirmationLines = []; | |
// Check regular text fields for changes | |
for (const inputId in fieldMap) { | |
const dataKey = fieldMap[inputId]; | |
const newValue = document.getElementById(inputId).value; | |
const originalValueText = document.getElementById('current_' + dataKey).textContent; | |
const originalValue = originalValueText === '[blank]' ? '' : originalValueText; | |
if (newValue !== originalValue) { | |
// Business phones array conversion to string | |
changes[dataKey] = newValue; | |
confirmationLines.push(dataKey + ': "' + originalValue + '" -> "' + newValue + '"'); | |
} | |
} | |
// Check for DisplayName, FirstName (GivenName), and LastName (Surname) changes | |
const newDisplayName = document.getElementById('edit_DisplayName').value; | |
const originalDisplayName = document.getElementById('current_DisplayName').textContent.replace('[blank]', ''); | |
if (newDisplayName !== originalDisplayName) { | |
changes['DisplayName'] = newDisplayName; | |
confirmationLines.push('DisplayName: "' + originalDisplayName + '" -> "' + newDisplayName + '"'); | |
} | |
const newFirstName = document.getElementById('edit_GivenName').value; | |
const originalFirstName = document.getElementById('current_GivenName').textContent.replace('[blank]', ''); | |
if (newFirstName !== originalFirstName) { | |
changes['GivenName'] = newFirstName; | |
confirmationLines.push('First Name: "' + originalFirstName + '" -> "' + newFirstName + '"'); | |
} | |
const newLastName = document.getElementById('edit_Surname').value; | |
const originalLastName = document.getElementById('current_Surname').textContent.replace('[blank]', ''); | |
if (newLastName !== originalLastName) { | |
changes['Surname'] = newLastName; | |
confirmationLines.push('Last Name: "' + originalLastName + '" -> "' + newLastName + '"'); | |
} | |
// Check for UPN change | |
const newUser = document.getElementById('edit_UserPrincipalName_User').value; | |
const newDomain = document.getElementById('edit_UserPrincipalName_Domain').value; | |
const newUpn = newUser + '@' + newDomain; | |
const currentUpn = document.getElementById('current_UserPrincipalName').textContent; | |
if (newUpn !== currentUpn) { | |
changes['UserPrincipalName'] = newUpn; | |
confirmationLines.push('UPN: "' + currentUpn + '" -> "' + newUpn + '"'); | |
} | |
// Check for UsageLocation change | |
const newUsageLocation = document.getElementById('edit_UsageLocation').value; | |
const originalUsageLocation = document.getElementById('current_UsageLocation').textContent.replace('[Not Set]', ''); | |
if (newUsageLocation !== originalUsageLocation) { | |
changes['usageLocation'] = newUsageLocation; | |
confirmationLines.push('Usage Location: "' + originalUsageLocation + '" -> "' + newUsageLocation + '"'); | |
} | |
// Check for Manager change | |
const newManager = document.getElementById('edit_Manager').value; | |
const originalManagerText = document.getElementById('current_ManagerDisplayName').textContent; | |
const originalManager = originalManagerText === '[blank]' ? '' : originalManagerText; | |
if (newManager !== originalManager) { | |
changes['Manager'] = newManager; | |
confirmationLines.push('Manager: "' + originalManager + '" -> "' + newManager + '"'); | |
} | |
console.log("DEBUG EDIT USER: Changes detected by UI:", changes); | |
if (Object.keys(changes).length === 0) { | |
updateStatusMessage('No changes detected.', 'info'); | |
return; | |
} | |
const confirmationMessage = "You are about to make the following changes:\n\n" + confirmationLines.join("\n") + "\n\nDo you want to proceed?"; | |
if (confirm(confirmationMessage)) { | |
const payload = { | |
upn: originalUpn, | |
changes: changes | |
}; | |
console.log("DEBUG EDIT USER: Payload being sent to backend:", JSON.stringify(payload, null, 2)); | |
updateStatusMessage('Saving changes...', 'info'); | |
fetch('/updateuserdetails', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}) | |
.then(res => { | |
console.log("DEBUG EDIT USER: Raw response from server, Status:", res.status, "OK:", res.ok); | |
const contentType = res.headers.get("content-type"); | |
if (contentType && contentType.indexOf("application/json") !== -1) { | |
return res.json(); | |
} else { | |
return res.text().then(text => { | |
throw new Error("Received non-JSON response from server: " + text); | |
}); | |
} | |
}) | |
.then(data => { | |
console.log("DEBUG EDIT USER: Parsed JSON response from server:", data); | |
if (data.error || data.success === false) { | |
throw new Error(data.error || data.status || "An unknown error occurred while saving."); | |
} | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
editUserDetailsModal.style.display = 'none'; | |
// Reload main dashboard to see changes | |
if (document.getElementById('loadUserBtn')) { | |
const newUpn = changes['UserPrincipalName']; | |
// Check if the UPN was part of the changes | |
if (newUpn) { | |
// If UPN was changed, inform the user about replication delay and wait before reloading | |
document.getElementById('usersearch').value = newUpn; | |
alert("User UPN was changed successfully to: " + newUpn + "\n\nIt may take a few moments for all Microsoft 365 services (like Exchange and Teams) to recognize this change.\n\nThe dashboard will reload now, but some details might take time to appear correctly."); | |
// Delay before reloading. 10s for now | |
updateStatusMessage("UPN changed. Waiting 10 seconds for service replication before reloading...", 'info'); | |
setTimeout(() => { | |
document.getElementById('loadUserBtn').click(); | |
}, 10000); // 10 seconds | |
} else { | |
// If UPN was not changed, reload immediately | |
document.getElementById('loadUserBtn').click(); | |
} | |
} | |
}) | |
.catch(error => { | |
console.error("DEBUG EDIT USER: Save failed:", error); | |
const statusContainer = document.getElementById('editUserModalStatusContainer'); | |
const statusMessage = document.getElementById('editUserModalStatusMessage'); | |
if(statusContainer && statusMessage) { | |
statusContainer.style.display = 'block'; | |
statusMessage.textContent = 'Save failed: ' + error.message; | |
} else { | |
updateStatusMessage('Save failed: ' + error.message, 'error'); | |
} | |
}); | |
} | |
}); | |
} | |
// Listener to CANCEL/CLOSE the Edit User Details modal | |
if (closeEditUserModalBtn) { | |
closeEditUserModalBtn.addEventListener('click', () => { | |
if (editUserDetailsModal) { | |
editUserDetailsModal.style.display = 'none'; | |
} | |
}); | |
} | |
// --- COMPLETE Manage Groups Modal Logic --- | |
// --- Variables --- | |
let grp_originalGroups = []; | |
let grp_stagedToAdd = new Set(); | |
let grp_stagedToRemove = new Set(); | |
// --- Helper Functions --- | |
function grp_updateUI() { | |
const currentGroupsList = document.getElementById('currentGroupsList'); | |
const addGroupsList = document.getElementById('addGroupsList'); | |
const removeGroupsList = document.getElementById('removeGroupsList'); | |
if (!currentGroupsList || !addGroupsList || !removeGroupsList) return; | |
currentGroupsList.innerHTML = ''; | |
addGroupsList.innerHTML = ''; | |
removeGroupsList.innerHTML = ''; | |
const currentGroupsSet = new Set(grp_originalGroups); | |
grp_stagedToRemove.forEach(group => currentGroupsSet.delete(group)); | |
currentGroupsSet.forEach(group => currentGroupsList.add(new Option(group, group))); | |
grp_stagedToAdd.forEach(group => addGroupsList.add(new Option(group, group))); | |
grp_stagedToRemove.forEach(group => removeGroupsList.add(new Option(group, group))); | |
document.getElementById('currentGroupsCount').textContent = currentGroupsList.options.length; | |
document.getElementById('addGroupsCount').textContent = addGroupsList.options.length; | |
document.getElementById('removeGroupsCount').textContent = removeGroupsList.options.length; | |
} | |
function grp_resetModal() { | |
grp_originalGroups = []; | |
grp_stagedToAdd.clear(); | |
grp_stagedToRemove.clear(); | |
['currentGroupsList', 'loadedCopyUserGroupsList', 'addGroupsList', 'removeGroupsList', 'copyGroupsFromUser', 'addGroupSearchInput'].forEach(id => { | |
const el = document.getElementById(id); | |
if (el) { | |
if (el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') el.innerHTML = ''; | |
else el.value = ''; | |
} | |
}); | |
grp_updateUI(); | |
} | |
// --- Event Listeners --- | |
const manageEditGroupsBtn = document.getElementById('manageEditGroupsBtn'); | |
if (manageEditGroupsBtn) { | |
manageEditGroupsBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
const modal = document.getElementById('manageGroupsModal'); | |
const userUpnSpan = document.getElementById('manageGroupsUserUpn'); | |
const statusContainer = document.getElementById('manageGroupsStatusContainer'); | |
const statusMessage = document.getElementById('manageGroupsStatusMessage'); | |
grp_resetModal(); | |
if (upn && !upn.startsWith('[')) { | |
userUpnSpan.textContent = upn; | |
statusContainer.style.display = 'block'; | |
statusMessage.textContent = 'Loading current groups...'; | |
fetch('/getuserdetails?upn=' + encodeURIComponent(upn)) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
grp_originalGroups = (data.GroupMemberships || []).map(g => g.DisplayName).sort((a,b) => a.localeCompare(b)); | |
grp_updateUI(); | |
statusContainer.style.display = 'none'; | |
}) | |
.catch(error => { statusMessage.textContent = 'Failed to load groups: ' + error.message; }); | |
} else { | |
userUpnSpan.textContent = '[No User Loaded]'; | |
} | |
modal.style.display = 'block'; | |
}); | |
} | |
document.getElementById('closeManageGroupsModalBtn')?.addEventListener('click', () => { document.getElementById('manageGroupsModal').style.display = 'none'; }); | |
document.getElementById('stageSelectedGroupsForRemovalBtn')?.addEventListener('click', () => { | |
Array.from(document.getElementById('currentGroupsList').selectedOptions).forEach(opt => grp_stagedToRemove.add(opt.value)); | |
grp_updateUI(); | |
}); | |
document.getElementById('stageAllGroupsForRemovalBtn')?.addEventListener('click', () => { | |
if (!confirm("Are you sure you want to stage ALL current group memberships for removal?\n\nPlease ensure you have a backup of the group list if needed.")) return; | |
Array.from(document.getElementById('currentGroupsList').options).forEach(opt => grp_stagedToRemove.add(opt.value)); | |
grp_updateUI(); | |
}); | |
document.getElementById('pickCopyUserBtn')?.addEventListener('click', () => { | |
const query = prompt("Search for a user to copy groups from:"); | |
if (!query) return; | |
const statusMsg = document.getElementById('manageGroupsStatusMessage'); | |
const statusContainer = document.getElementById('manageGroupsStatusContainer'); | |
statusMsg.textContent = 'Searching...'; statusContainer.style.display = 'block'; | |
fetch('/search?query=' + encodeURIComponent(query)).then(res => res.json()).then(results => { | |
if (results.error || results.length === 0) { throw new Error(results.error || 'No users found.'); } | |
let msg = "Select a user:\n" + results.map((u, i) => (i + 1) + ". " + u.displayName + " (" + u.upn + ")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
document.getElementById('copyGroupsFromUser').value = results[choice - 1].upn; | |
statusContainer.style.display = 'none'; | |
} | |
}).catch(err => { statusMsg.textContent = 'User search failed: ' + err.message; }); | |
}); | |
const groupSearchInputForEnter = document.getElementById('addGroupSearchInput'); | |
if (groupSearchInputForEnter) { | |
groupSearchInputForEnter.addEventListener('keypress', function(e) { | |
if (e.key === 'Enter') { | |
e.preventDefault(); // Prevent default browser action | |
document.getElementById('searchAndAddGroupBtn')?.click(); // Trigger search button click | |
} | |
}); | |
} | |
document.getElementById('loadCopyUserGroupsBtn')?.addEventListener('click', () => { | |
const upn = document.getElementById('copyGroupsFromUser').value; | |
if (!upn) { alert('Please pick a user to copy from first.'); return; } | |
const statusMsg = document.getElementById('manageGroupsStatusMessage'); | |
const statusContainer = document.getElementById('manageGroupsStatusContainer'); | |
statusMsg.textContent = 'Loading groups for ' + upn + '...'; | |
statusContainer.style.display = 'block'; | |
fetch('/getuserdetails?upn=' + encodeURIComponent(upn)).then(res => res.json()).then(data => { | |
const list = document.getElementById('loadedCopyUserGroupsList'); | |
list.innerHTML = ''; | |
if (data.GroupMemberships && data.GroupMemberships.length > 0) { | |
(data.GroupMemberships || []).map(g => g.DisplayName).sort((a,b) => a.localeCompare(b)).forEach(name => list.add(new Option(name, name))); | |
} | |
document.getElementById('copyUserGroupsCount').textContent = list.options.length; | |
statusContainer.style.display = 'none'; | |
}).catch(err => { statusMsg.textContent = 'Failed to load groups: ' + err.message; }); | |
}); | |
document.getElementById('stageSelectedCopiedGroupsBtn')?.addEventListener('click', () => { | |
Array.from(document.getElementById('loadedCopyUserGroupsList').selectedOptions).forEach(opt => grp_stagedToAdd.add(opt.value)); | |
grp_updateUI(); | |
}); | |
document.getElementById('stageAllCopiedGroupsBtn')?.addEventListener('click', () => { | |
Array.from(document.getElementById('loadedCopyUserGroupsList').options).forEach(opt => grp_stagedToAdd.add(opt.value)); | |
grp_updateUI(); | |
}); | |
document.getElementById('unstageSelectedAddBtn')?.addEventListener('click', () => { | |
Array.from(document.getElementById('addGroupsList').selectedOptions).forEach(opt => grp_stagedToAdd.delete(opt.value)); | |
grp_updateUI(); | |
}); | |
document.getElementById('unstageAllAddBtn')?.addEventListener('click', () => { grp_stagedToAdd.clear(); grp_updateUI(); }); | |
document.getElementById('unstageSelectedRemoveBtn')?.addEventListener('click', () => { | |
Array.from(document.getElementById('removeGroupsList').selectedOptions).forEach(opt => grp_stagedToRemove.delete(opt.value)); | |
grp_updateUI(); | |
}); | |
document.getElementById('unstageAllRemoveBtn')?.addEventListener('click', () => { grp_stagedToRemove.clear(); grp_updateUI(); }); | |
document.getElementById('saveGroupChangesBtn')?.addEventListener('click', () => { | |
const upn = document.getElementById('manageGroupsUserUpn').textContent; | |
if (!upn || upn.startsWith('[')) { alert('No user loaded.'); return; } | |
const originalSet = new Set(grp_originalGroups); | |
const groupsToAdd = [...grp_stagedToAdd].filter(g => !originalSet.has(g)); | |
const groupsToRemove = [...grp_stagedToRemove].filter(g => originalSet.has(g)); | |
if (groupsToAdd.length === 0 && groupsToRemove.length === 0) { alert('No effective changes to save.'); return; } | |
let confirmMsg = 'You are about to apply the following group changes for ' + upn + ':\n\n'; | |
if (groupsToAdd.length > 0) confirmMsg += 'ADD ' + groupsToAdd.length + ' group(s):\n - ' + groupsToAdd.join('\n - ') + '\n'; | |
if (groupsToRemove.length > 0) confirmMsg += '\nREMOVE ' + groupsToRemove.length + ' group(s):\n - ' + groupsToRemove.join('\n - ') + '\n'; | |
confirmMsg += '\nProceed?'; | |
if (confirm(confirmMsg)) { | |
const payload = { upn: upn, groupsToAdd: groupsToAdd, groupsToRemove: groupsToRemove }; | |
const statusMsg = document.getElementById('manageGroupsStatusMessage'); | |
const statusContainer = document.getElementById('manageGroupsStatusContainer'); | |
statusMsg.textContent = 'Saving changes...'; statusContainer.style.display = 'block'; | |
fetch('/updategroupmemberships', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
document.getElementById('manageGroupsModal').style.display = 'none'; | |
if (document.getElementById('loadUserBtn')) document.getElementById('loadUserBtn').click(); | |
}) | |
.catch(error => { statusMsg.textContent = 'Save failed: ' + error.message; }); | |
} | |
}); | |
// Listener for the "Search" button in the "Groups to Add" panel | |
document.getElementById('searchAndAddGroupBtn')?.addEventListener('click', () => { | |
const queryInput = document.getElementById('addGroupSearchInput'); | |
if (!queryInput) { | |
console.error("Could not find group search input with ID: addGroupSearchInput"); | |
return; | |
} | |
const query = queryInput.value; | |
if (!query || query.trim() === "") { | |
alert("Please enter a group name to search for."); | |
return; | |
} | |
const statusMsg = document.getElementById('manageGroupsStatusMessage'); | |
const statusContainer = document.getElementById('manageGroupsStatusContainer'); | |
statusMsg.textContent = 'Searching for group "' + query + '"...'; | |
statusContainer.style.display = 'block'; | |
fetch('/searchgroups?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
console.log("RAW DATA RECEIVED FROM /searchgroups:", results); | |
if (results && results.length > 0) { | |
console.log("Properties of the first group object:", Object.keys(results[0])); | |
} | |
if (results.error || !Array.isArray(results) || results.length === 0) { | |
throw new Error(results.error || 'No groups found.'); | |
} | |
// Prompt message | |
let msg = "Select a group to add:\n\n"; | |
results.forEach((g, i) => { | |
// Display name using common variations | |
const name = g.displayName || g.DisplayName || g.displayname || g.Name || "[Unknown Name]"; | |
msg += (i + 1) + ". " + name + "\n"; | |
}); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedGroup = results[choice - 1]; | |
const selectedGroupName = selectedGroup.displayName || selectedGroup.DisplayName || selectedGroup.displayname || selectedGroup.Name; | |
if (selectedGroupName) { | |
grp_stagedToAdd.add(selectedGroupName); | |
grp_updateUI(); | |
queryInput.value = ''; | |
statusMsg.textContent = 'Group "' + selectedGroupName + '" staged for addition.'; | |
} else { | |
statusMsg.textContent = 'Error: Could not determine name of selected group.'; | |
} | |
} else { | |
statusContainer.style.display = 'none'; | |
} | |
}) | |
.catch(err => { | |
statusMsg.textContent = 'Group search failed: ' + err.message; | |
}); | |
}); | |
// --- COMPLETE Manage Licenses Modal Logic --- | |
const manageLicensesModal = document.getElementById('manageLicensesModal'); | |
const manageLicensesBtn = document.getElementById('manageLicensesBtn'); | |
const closeManageLicensesModalBtn = document.getElementById('closeManageLicensesModalBtn'); | |
const saveLicenseChangesBtn = document.getElementById('saveLicenseChangesBtn'); | |
const currentUserLicensesList = document.getElementById('currentUserLicensesList'); | |
const availableTenantLicensesList = document.getElementById('availableTenantLicensesList'); | |
const addLicensesList = document.getElementById('addLicensesList'); | |
const removeLicensesList = document.getElementById('removeLicensesList'); | |
const filterAvailableLicensesInput = document.getElementById('filterAvailableLicenses'); | |
const stageLicenseForRemovalBtn = document.getElementById('stageLicenseForRemovalBtn'); | |
const stageLicenseForAdditionBtn = document.getElementById('stageLicenseForAdditionBtn'); | |
const unstageAddLicenseBtn = document.getElementById('unstageAddLicenseBtn'); | |
const unstageRemoveLicenseBtn = document.getElementById('unstageRemoveLicenseBtn'); | |
let allTenantLicensesCache = []; // To store all tenant licenses for filtering | |
function updateLicenseCounts() { | |
document.getElementById('currentUserLicenseCount').textContent = currentUserLicensesList.options.length; | |
document.getElementById('availableTenantLicenseCount').textContent = availableTenantLicensesList.options.length; | |
document.getElementById('addLicenseCount').textContent = addLicensesList.options.length; | |
document.getElementById('removeLicenseCount').textContent = removeLicensesList.options.length; | |
} | |
function populateLicenseList(listElement, licenses, displayType = 'friendlyNameOnly') { | |
listElement.innerHTML = ''; | |
if (!licenses) return; | |
licenses.forEach(lic => { | |
let displayText = lic.friendlyName; | |
if (displayType === 'full') { | |
displayText = lic.friendlyName + ' (' + lic.available + '/' + lic.total + ' avail)'; | |
} | |
const option = new Option(displayText, lic.skuId); // Value is SkuId (GUID) | |
option.dataset.skuPartNumber = lic.skuPartNumber || ''; // Store SkuPartNumber if available | |
option.dataset.friendlyName = lic.friendlyName; // Store friendly name | |
listElement.add(option); | |
}); | |
updateLicenseCounts(); | |
} | |
if (manageLicensesBtn) { | |
manageLicensesBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
const userUpnSpan = document.getElementById('manageLicensesUserUpn'); | |
const statusContainer = document.getElementById('manageLicensesStatusContainer'); | |
const statusMessage = document.getElementById('manageLicensesStatusMessage'); | |
// Reset UI elements | |
[currentUserLicensesList, availableTenantLicensesList, addLicensesList, removeLicensesList].forEach(el => el.innerHTML = ''); | |
filterAvailableLicensesInput.value = ''; | |
allTenantLicensesCache = []; // Clear cache | |
updateLicenseCounts(); | |
statusContainer.style.display = 'block'; // Show status container initially | |
statusMessage.textContent = 'Loading license information...'; | |
// Default button states | |
saveLicenseChangesBtn.disabled = true; | |
stageLicenseForRemovalBtn.disabled = true; | |
stageLicenseForAdditionBtn.disabled = true; | |
const tenantLicensesPromise = fetch('/gettenantlicenses') | |
.then(response => { | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('Failed to load tenant licenses: HTTP ' + response.status + ' ' + response.statusText); | |
}).then(err => { | |
throw new Error(err.error || 'Failed to load tenant licenses: HTTP ' + response.status + ' ' + response.statusText); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
allTenantLicensesCache = data.licenses || []; | |
populateLicenseList(availableTenantLicensesList, allTenantLicensesCache, 'full'); | |
return data; // Pass data along | |
}); | |
if (upn && !upn.startsWith('[')) { | |
// User is loaded | |
userUpnSpan.textContent = upn; | |
// Enable buttons that depend on a loaded user after data fetches | |
const userLicensesPromise = fetch('/getuserlicenses?upn=' + encodeURIComponent(upn)) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('Failed to load user licenses: HTTP ' + response.status + ' ' + response.statusText); | |
}).then(err => { | |
throw new Error(err.error || 'Failed to load user licenses: HTTP ' + response.status + ' ' + response.statusText); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
populateLicenseList(currentUserLicensesList, data.licenses, 'friendlyNameOnly'); | |
return data; | |
}); | |
Promise.all([tenantLicensesPromise, userLicensesPromise]) | |
.then(() => { | |
statusContainer.style.display = 'none'; | |
saveLicenseChangesBtn.disabled = false; | |
stageLicenseForRemovalBtn.disabled = false; | |
stageLicenseForAdditionBtn.disabled = false; | |
}) | |
.catch(error => { | |
statusMessage.textContent = 'Error loading details: ' + error.message; | |
// Keep buttons disabled as data might be incomplete | |
}) | |
.finally(() => { | |
manageLicensesModal.style.display = 'block'; | |
}); | |
} else { | |
// No user loaded | |
userUpnSpan.textContent = '[No User Loaded]'; | |
// Buttons remain disabled (save, stage for removal, stage for add) | |
tenantLicensesPromise | |
.then(() => { | |
statusContainer.style.display = 'none'; | |
}) | |
.catch(error => { | |
statusMessage.textContent = 'Error loading tenant licenses: ' + error.message; | |
}) | |
.finally(() => { | |
manageLicensesModal.style.display = 'block'; | |
}); | |
} | |
}); | |
} | |
// Filter for available licenses | |
if (filterAvailableLicensesInput) { | |
filterAvailableLicensesInput.addEventListener('input', (e) => { | |
const filterText = e.target.value.toLowerCase(); | |
const filteredLicenses = allTenantLicensesCache.filter(lic => | |
lic.friendlyName.toLowerCase().includes(filterText) || | |
(lic.skuPartNumber && lic.skuPartNumber.toLowerCase().includes(filterText)) | |
); | |
populateLicenseList(availableTenantLicensesList, filteredLicenses, 'full'); | |
}); | |
} | |
// Staging/Queueing button logic | |
if (stageLicenseForAdditionBtn) { | |
stageLicenseForAdditionBtn.addEventListener('click', () => { | |
Array.from(availableTenantLicensesList.selectedOptions).forEach(opt => { | |
// Prevent adding if already in "to add" or "current" (unless staged for removal) | |
const alreadyInAdd = Array.from(addLicensesList.options).some(addOpt => addOpt.value === opt.value); | |
const inCurrentNotRemoving = Array.from(currentUserLicensesList.options).some(currOpt => currOpt.value === opt.value) && | |
!Array.from(removeLicensesList.options).some(remOpt => remOpt.value === opt.value); | |
if (!alreadyInAdd && !inCurrentNotRemoving) { | |
const newOpt = new Option(opt.dataset.friendlyName, opt.value); // Display only friendly name | |
newOpt.dataset.skuPartNumber = opt.dataset.skuPartNumber; | |
newOpt.dataset.friendlyName = opt.dataset.friendlyName; | |
addLicensesList.add(newOpt); | |
} | |
}); | |
updateLicenseCounts(); | |
}); | |
} | |
if (stageLicenseForRemovalBtn) { | |
stageLicenseForRemovalBtn.addEventListener('click', () => { | |
Array.from(currentUserLicensesList.selectedOptions).forEach(opt => { | |
// Prevent adding if already in "to remove" | |
const alreadyInRemove = Array.from(removeLicensesList.options).some(remOpt => remOpt.value === opt.value); | |
if (!alreadyInRemove) { | |
const newOpt = new Option(opt.dataset.friendlyName, opt.value); | |
newOpt.dataset.skuPartNumber = opt.dataset.skuPartNumber; | |
newOpt.dataset.friendlyName = opt.dataset.friendlyName; | |
removeLicensesList.add(newOpt); | |
} | |
}); | |
updateLicenseCounts(); | |
}); | |
} | |
if (unstageAddLicenseBtn) { | |
unstageAddLicenseBtn.addEventListener('click', () => { | |
Array.from(addLicensesList.selectedOptions).forEach(opt => addLicensesList.remove(opt.index)); | |
updateLicenseCounts(); | |
}); | |
} | |
if (unstageRemoveLicenseBtn) { | |
unstageRemoveLicenseBtn.addEventListener('click', () => { | |
Array.from(removeLicensesList.selectedOptions).forEach(opt => removeLicensesList.remove(opt.index)); | |
updateLicenseCounts(); | |
}); | |
} | |
// Save License Management Changes | |
if (saveLicenseChangesBtn) { | |
saveLicenseChangesBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageLicensesUserUpn').textContent; | |
if (!upn || upn.startsWith('[')) { | |
document.getElementById('manageLicensesStatusMessage').textContent = 'Cannot save, no target user is loaded.'; | |
return; | |
} | |
const licensesToAdd = Array.from(addLicensesList.options).map(opt => ({ skuId: opt.value, friendlyName: opt.dataset.friendlyName })); | |
const licensesToRemove = Array.from(removeLicensesList.options).map(opt => ({ skuId: opt.value, friendlyName: opt.dataset.friendlyName })); | |
if (licensesToAdd.length === 0 && licensesToRemove.length === 0) { | |
document.getElementById('manageLicensesStatusMessage').textContent = 'No license changes to apply.'; | |
return; | |
} | |
let confirmMsg = 'You are about to apply the following license changes for ' + upn + ':\n\n'; | |
if (licensesToAdd.length > 0) { | |
confirmMsg += 'ADD:\n'; | |
licensesToAdd.forEach(lic => confirmMsg += ' - ' + lic.friendlyName + '\n'); | |
} | |
if (licensesToRemove.length > 0) { | |
confirmMsg += 'REMOVE:\n'; | |
licensesToRemove.forEach(lic => confirmMsg += ' - ' + lic.friendlyName + '\n'); | |
} | |
confirmMsg += '\nProceed?'; | |
if (confirm(confirmMsg)) { | |
const payload = { | |
upn: upn, | |
licensesToAdd: licensesToAdd.map(lic => lic.skuId), // Send only SkuIds | |
licensesToRemove: licensesToRemove.map(lic => lic.skuId) // Send only SkuIds | |
}; | |
document.getElementById('manageLicensesStatusMessage').textContent = 'Saving license changes...'; | |
fetch('/updateuserlicenses', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
manageLicensesModal.style.display = 'none'; | |
if (document.getElementById('loadUserBtn')) document.getElementById('loadUserBtn').click(); // Refresh main dashboard | |
}) | |
.catch(error => { | |
document.getElementById('manageLicensesStatusMessage').textContent = 'Save failed: ' + error.message; | |
}); | |
} | |
}); | |
} | |
// Close Manage License modal | |
if (closeManageLicensesModalBtn) { | |
closeManageLicensesModalBtn.addEventListener('click', () => { | |
manageLicensesModal.style.display = 'none'; | |
}); | |
} | |
// --- Change Mailbox Type Logic --- | |
const manageChangeMailboxTypeBtn = document.getElementById('manageChangeMailboxTypeBtn'); | |
if (manageChangeMailboxTypeBtn) { | |
manageChangeMailboxTypeBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
const statusContainer = document.getElementById('manageModalStatusContainer'); | |
// const statusMessageSpan = document.getElementById('manageModalStatusMessage'); | |
if (!upn || upn.startsWith('[') || upn === '[No User Loaded]') { | |
showManageModalStatus("Please load a user in the main dashboard first.", 'error'); | |
return; | |
} | |
// When a USER IS LOADED | |
if (statusContainer) { | |
statusContainer.style.display = 'block'; | |
} | |
updateStatusMessage('Fetching current mailbox and license status...', 'info'); | |
fetch('/getmailboxandlicensestatus?upn=' + encodeURIComponent(upn)) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('HTTP error ' + response.status); | |
}).then(err => { | |
throw new Error(err.error || 'HTTP error ' + response.status); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) { | |
throw new Error(data.error); | |
} | |
const currentType = data.recipientTypeDetails; | |
const isLicensed = data.isLicensed; | |
let targetType = ''; | |
let confirmMessage = ''; | |
if (!currentType || currentType.toLowerCase() === 'none' || currentType.toLowerCase() === 'discoverymailbox') { | |
alert('User ' + upn + ' does not have a mailbox that can be converted by this function (Type: ' + (currentType || 'N/A') + ').'); | |
if (statusContainer) statusContainer.style.display = 'none'; | |
return; | |
} | |
if (currentType.toLowerCase() === 'usermailbox') { | |
targetType = 'Shared'; | |
confirmMessage = 'Are you sure you want to convert this User Mailbox to a Shared Mailbox for ' + upn + '?'; | |
} else if (currentType.toLowerCase() === 'sharedmailbox') { | |
targetType = 'Regular'; | |
confirmMessage = 'Are you sure you want to convert this Shared Mailbox to a User Mailbox for ' + upn + '?'; | |
if (!isLicensed) { | |
confirmMessage += '\n\nWARNING: This user account currently appears to be unlicensed for an Exchange Online mailbox. Converting to a User Mailbox without an appropriate license may result in the mailbox being soft-deleted or becoming inaccessible after a grace period.'; | |
} | |
} else { | |
alert('This mailbox is currently a ' + currentType + '. This function can only convert between User and Shared mailboxes.'); | |
if (statusContainer) statusContainer.style.display = 'none'; | |
return; | |
} | |
if (confirm(confirmMessage)) { | |
if (statusContainer) statusContainer.classList.remove('hidden'); | |
updateStatusMessage('Converting mailbox to ' + targetType + '...', 'info'); | |
const payload = { | |
upn: upn, | |
targetType: targetType | |
}; | |
return fetch('/changemailboxtype', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
} else { | |
if (statusContainer) statusContainer.style.display = 'block'; | |
updateStatusMessage('Mailbox conversion cancelled.', 'info'); | |
return Promise.reject('cancelled'); | |
} | |
}) | |
.then(response => { | |
if (!response) return; | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('HTTP error ' + response.status); | |
}).then(err => { | |
throw new Error(err.error || 'HTTP error ' + response.status); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
if (!data) return; | |
if (data.error) { | |
throw new Error(data.error); | |
} | |
if (statusContainer) statusContainer.style.display = 'block'; | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
setTimeout(() => { | |
if (document.getElementById('usersearch').value === upn) { | |
document.getElementById('loadUserBtn').click(); | |
} | |
}, 1000); | |
}) | |
.catch(error => { | |
if (error !== 'cancelled') { | |
if (statusContainer) statusContainer.style.display = 'block'; | |
updateStatusMessage('Failed: ' + error.message, 'error'); | |
} | |
}); | |
}); | |
} | |
// --- COMPLETE Manage Aliases Modal Logic --- | |
const manageAliasesModal = document.getElementById('manageAliasesModal'); | |
const manageEditAliasesBtn = document.getElementById('manageEditAliasesBtn'); // Button from main manage modal | |
const closeManageAliasesModalBtn = document.getElementById('closeManageAliasesModalBtn'); | |
const saveAliasChangesBtn = document.getElementById('saveAliasChangesBtn'); | |
const manageAliasesUserUpnSpan = document.getElementById('manageAliasesUserUpn'); | |
const manageAliasesStatusContainer = document.getElementById('manageAliasesStatusContainer'); | |
const manageAliasesStatusMessage = document.getElementById('manageAliasesStatusMessage'); | |
const currentAliasesList = document.getElementById('currentAliasesList'); | |
const currentAliasesCountSpan = document.getElementById('currentAliasesCount'); | |
const removeSelectedAliasBtn = document.getElementById('removeSelectedAliasBtn'); | |
const setAsPrimaryBtn = document.getElementById('setAsPrimaryBtn'); | |
const newAliasInput = document.getElementById('newAliasInput'); | |
const aliasDomainDropdown = document.getElementById('aliasDomainDropdown'); | |
const addNewAliasBtn = document.getElementById('addNewAliasBtn'); | |
let initialAliasesCache = []; // To store the original list for comparison on save | |
let initialPrimarySmtpCache = ''; | |
function updateAliasesCount() { | |
currentAliasesCountSpan.textContent = currentAliasesList.options.length; | |
} | |
function populateAliasesList(aliases, primarySmtp) { | |
currentAliasesList.innerHTML = ''; | |
if (!aliases || aliases.length === 0) { | |
updateAliasesCount(); | |
return; | |
} | |
aliases.sort().forEach(alias => { | |
let displayText = alias; | |
const isPrimary = alias.toUpperCase().startsWith('SMTP:') && alias.substring(5).toLowerCase() === primarySmtp.toLowerCase(); | |
if (isPrimary) { | |
displayText = alias.substring(5) + ' (Primary)'; // Show without "SMTP:" prefix for primary display | |
initialPrimarySmtpCache = alias.substring(5); | |
} else if (alias.toLowerCase().startsWith('smtp:')) { | |
displayText = alias.substring(5); // Show without "smtp:" | |
} | |
// Store the full alias (with prefix) in the value | |
currentAliasesList.add(new Option(displayText, alias)); | |
}); | |
updateAliasesCount(); | |
} | |
// Listener to OPEN the Manage Alias modal | |
if (manageEditAliasesBtn) { | |
manageEditAliasesBtn.addEventListener('click', () => { | |
const upnFromManageModal = document.getElementById('manageModalUserUpn').textContent; | |
// --- Reset the modal to its initial state --- | |
if (typeof resetAliasFormAndDisableControls === 'function') { | |
resetAliasFormAndDisableControls(); | |
} else { | |
currentAliasesList.innerHTML = ''; | |
newAliasInput.value = ''; | |
updateAliasesCount(); | |
initialAliasesCache = []; | |
initialPrimarySmtpCache = ''; | |
addNewAliasBtn.disabled = true; | |
removeSelectedAliasBtn.disabled = true; | |
setAsPrimaryBtn.disabled = true; | |
saveAliasChangesBtn.disabled = true; | |
} | |
manageAliasesModal.style.display = 'block'; // Show modal immediately | |
manageAliasesStatusContainer.style.display = 'none'; | |
aliasDomainDropdown.innerHTML = '<option value="">Loading domains...</option>'; | |
// --- Fetch and Populate the Domain Dropdown --- | |
fetch('/gettenantdomains') | |
.then(response => { | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'HTTP Error ' + response.status); }); | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
aliasDomainDropdown.innerHTML = ''; // Clear "Loading..." message | |
if (data.domains && data.domains.length > 0) { | |
data.domains.forEach(domain => { | |
aliasDomainDropdown.add(new Option(domain, domain)); | |
}); | |
// Set the dropdown's value to the tenant's default domain | |
if (data.defaultDomain) { | |
aliasDomainDropdown.value = data.defaultDomain; | |
} | |
} else { | |
aliasDomainDropdown.add(new Option('No domains found', '')); | |
} | |
}) | |
.catch(error => { | |
console.error("Failed to load domains for alias dropdown:", error); | |
if (aliasDomainDropdown) aliasDomainDropdown.innerHTML = '<option value="">Error loading</option>'; | |
}); | |
// --- Username suggestion logic --- | |
if (upnFromManageModal && !upnFromManageModal.startsWith('[')) { | |
manageAliasesUserUpnSpan.textContent = upnFromManageModal; | |
manageAliasesStatusContainer.style.display = 'block'; | |
manageAliasesStatusMessage.textContent = 'Loading aliases for ' + upnFromManageModal + '...'; | |
// Check sync status to enable/disable controls. Cloud or Dirsynced user | |
fetch('/getsyncstatus?upn=' + encodeURIComponent(upnFromManageModal)) | |
.then(res => res.json()) | |
.then(syncData => { | |
if (syncData.error) throw new Error(syncData.error); | |
if (syncData.onPremisesSyncEnabled === true) { | |
manageAliasesStatusMessage.textContent = 'Synced user: Aliases are read-only.'; | |
// Controls remain disabled from reset | |
} else { | |
// Cloud-only user, enable controls | |
if (typeof enableAliasControls === 'function') enableAliasControls(); | |
else { | |
addNewAliasBtn.disabled = false; | |
removeSelectedAliasBtn.disabled = false; | |
setAsPrimaryBtn.disabled = false; | |
saveAliasChangesBtn.disabled = false; | |
} | |
manageAliasesStatusMessage.textContent = 'Loading aliases...'; | |
return fetch('/getuseraliases?upn=' + encodeURIComponent(upnFromManageModal)); | |
} | |
}) | |
.then(response => { | |
if(!response) return; // For synced user, response will be undefined. | |
return response.json(); | |
}) | |
.then(aliasData => { | |
if(!aliasData) return; | |
if (aliasData.error) throw new Error(aliasData.error); | |
initialAliasesCache = [...(aliasData.proxyAddresses || [])]; | |
populateAliasesList(aliasData.proxyAddresses, aliasData.primarySmtp || ''); | |
const loadedUserFirstName = aliasData.firstName || ''; | |
const loadedUserLastName = aliasData.lastName || ''; | |
const aliasDatalist = document.getElementById('alias-suggestions'); | |
if (aliasDatalist) { | |
const suggestions = generateUsernameFormats(loadedUserFirstName, loadedUserLastName); | |
aliasDatalist.innerHTML = ''; | |
suggestions.forEach(suggestion => { | |
aliasDatalist.insertAdjacentHTML('beforeend', '<option value="' + suggestion + '"></option>'); | |
}); | |
} | |
manageAliasesStatusContainer.style.display = 'none'; | |
}) | |
.catch(error => { | |
manageAliasesStatusMessage.textContent = 'Failed to load user data: ' + error.message; | |
if (typeof resetAliasFormAndDisableControls === 'function') resetAliasFormAndDisableControls(); | |
}); | |
} else { | |
manageAliasesUserUpnSpan.textContent = '[No User Loaded]'; | |
if (typeof resetAliasFormAndDisableControls === 'function') resetAliasFormAndDisableControls(); | |
} | |
}); | |
} | |
// --- Helper Functions for alias modal logic --- | |
function resetAliasFormAndDisableControls() { | |
// Reset the form and ensure all editing controls are disabled by default | |
currentAliasesList.innerHTML = ''; | |
newAliasInput.value = ''; | |
updateAliasesCount(); | |
initialAliasesCache = []; | |
initialPrimarySmtpCache = ''; | |
addNewAliasBtn.disabled = true; | |
removeSelectedAliasBtn.disabled = true; | |
setAsPrimaryBtn.disabled = true; | |
saveAliasChangesBtn.disabled = true; | |
} | |
function enableAliasControls() { | |
// Enable controls for cloud-only users | |
addNewAliasBtn.disabled = false; | |
removeSelectedAliasBtn.disabled = false; | |
setAsPrimaryBtn.disabled = false; | |
saveAliasChangesBtn.disabled = false; | |
} | |
// Add new alias to the list | |
if (addNewAliasBtn) { | |
addNewAliasBtn.addEventListener('click', () => { | |
const aliasUserPart = newAliasInput.value.trim(); | |
const selectedDomain = aliasDomainDropdown.value; | |
if (!aliasUserPart || !selectedDomain) { | |
manageAliasesStatusMessage.textContent = 'Please enter the alias prefix and select a domain.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
// Basic email format check for the user part (before @) | |
if (!/^[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~.]+$/.test(aliasUserPart)) { | |
manageAliasesStatusMessage.textContent = 'Invalid characters in alias prefix.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
const fullNewAlias = 'smtp:' + aliasUserPart + '@' + selectedDomain; // New aliases are always secondary smtp initially | |
const newAliasDisplay = aliasUserPart + '@' + selectedDomain; | |
// Check if alias already exists in the list (either as SMTP: or smtp:) | |
let exists = false; | |
for (let i = 0; i < currentAliasesList.options.length; i++) { | |
const optionValue = currentAliasesList.options[i].value; | |
if (optionValue.substring(optionValue.indexOf(':') + 1).toLowerCase() === newAliasDisplay.toLowerCase()) { | |
exists = true; | |
break; | |
} | |
} | |
if (exists) { | |
manageAliasesStatusMessage.textContent = 'This alias already exists in the list.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
} else { | |
currentAliasesList.add(new Option(newAliasDisplay, fullNewAlias)); | |
newAliasInput.value = ''; // Clear input | |
updateAliasesCount(); | |
manageAliasesStatusContainer.style.display = 'none'; | |
} | |
}); | |
} | |
// Remove selected alias | |
if (removeSelectedAliasBtn) { | |
removeSelectedAliasBtn.addEventListener('click', () => { | |
const selectedIndex = currentAliasesList.selectedIndex; | |
if (selectedIndex === -1) { | |
if (manageAliasesStatusMessage) manageAliasesStatusMessage.textContent = 'Please select an alias to remove.'; | |
if (manageAliasesStatusContainer) manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
const selectedOption = currentAliasesList.options[selectedIndex]; | |
// Check the original string for the uppercase "SMTP:" prefix without converting the whole string first. | |
if (selectedOption.value.startsWith('SMTP:')) { | |
if (manageAliasesStatusMessage) manageAliasesStatusMessage.textContent = 'Cannot remove the primary SMTP alias directly. Set another alias as primary first.'; | |
if (manageAliasesStatusContainer) manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
// If it's a secondary alias, remove it from the listbox. | |
currentAliasesList.remove(selectedIndex); | |
updateAliasesCount(); | |
if (manageAliasesStatusContainer) manageAliasesStatusContainer.style.display = 'none'; | |
}); | |
} | |
// Set selected alias as primary | |
if (setAsPrimaryBtn) { | |
setAsPrimaryBtn.addEventListener('click', () => { | |
const selectedIndex = currentAliasesList.selectedIndex; | |
if (selectedIndex === -1) { | |
manageAliasesStatusMessage.textContent = 'Please select an alias to set as primary.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
for (let i = 0; i < currentAliasesList.options.length; i++) { | |
const option = currentAliasesList.options[i]; | |
let emailPart = option.value.substring(option.value.indexOf(':') + 1); | |
if (i === selectedIndex) { | |
option.value = 'SMTP:' + emailPart; // Uppercase SMTP for primary | |
option.text = emailPart + ' (Primary)'; | |
} else { | |
option.value = 'smtp:' + emailPart; // Lowercase smtp for secondary | |
option.text = emailPart; // Remove (Primary) tag from previous SMTP | |
} | |
} | |
manageAliasesStatusContainer.style.display = 'none'; | |
}); | |
} | |
// Save alias changes | |
if (saveAliasChangesBtn) { | |
saveAliasChangesBtn.addEventListener('click', () => { | |
const upn = manageAliasesUserUpnSpan.textContent; | |
if (!upn || upn.startsWith('[')) { | |
manageAliasesStatusMessage.textContent = 'No user loaded to save changes for.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
// --- Collect the final states from the UI --- | |
const finalProxyAddresses = []; | |
let newPrimarySmtp = ''; | |
for (let i = 0; i < currentAliasesList.options.length; i++) { | |
const optionValue = currentAliasesList.options[i].value; | |
finalProxyAddresses.push(optionValue); | |
// Correctly find the primary SMTP without using toUpperCase() | |
if (optionValue.startsWith('SMTP:')) { | |
newPrimarySmtp = optionValue.substring(5); | |
} | |
} | |
if (!newPrimarySmtp) { | |
manageAliasesStatusMessage.textContent = 'Error: A primary SMTP address must be set before saving.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
// --- Determine the actual changes --- | |
const initialSet = new Set(initialAliasesCache); | |
const finalSet = new Set(finalProxyAddresses); | |
const addedAliases = finalProxyAddresses.filter(alias => !initialSet.has(alias)); | |
const removedAliases = initialAliasesCache.filter(alias => !finalSet.has(alias)); | |
const primaryChanged = newPrimarySmtp.toLowerCase() !== initialPrimarySmtpCache.toLowerCase(); | |
if (addedAliases.length === 0 && removedAliases.length === 0 && !primaryChanged) { | |
manageAliasesStatusMessage.textContent = 'No changes to save.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
return; | |
} | |
// --- Confirmation message --- | |
let confirmMsg = 'You are about to make the following alias changes for ' + upn + ':\n\n'; | |
if (primaryChanged) { | |
confirmMsg += 'SET NEW PRIMARY: ' + newPrimarySmtp + '\n'; | |
} | |
if (addedAliases.length > 0) { | |
confirmMsg += 'ADD:\n'; | |
addedAliases.forEach(alias => confirmMsg += ' - ' + alias.substring(5) + '\n'); | |
} | |
if (removedAliases.length > 0) { | |
confirmMsg += 'REMOVE:\n'; | |
removedAliases.forEach(alias => confirmMsg += ' - ' + alias.substring(5) + '\n'); | |
} | |
confirmMsg += '\nProceed?'; | |
if (confirm(confirmMsg)) { | |
const payload = { | |
upn: upn, | |
newProxyAddresses: finalProxyAddresses, | |
newPrimarySmtp: newPrimarySmtp | |
}; | |
manageAliasesStatusMessage.textContent = 'Saving alias changes...'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
fetch('/updateuseraliases', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}) | |
.then(response => { | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'HTTP Error ' + response.status); }); | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
manageAliasesModal.style.display = 'none'; | |
if (document.getElementById('loadUserBtn')) document.getElementById('loadUserBtn').click(); | |
}) | |
.catch(error => { | |
manageAliasesStatusMessage.textContent = 'Save failed: ' + error.message; | |
}); | |
} else { | |
manageAliasesStatusMessage.textContent = 'Save cancelled.'; | |
manageAliasesStatusContainer.style.display = 'block'; | |
} | |
}); | |
} | |
// Close Manage Alias modal | |
if (closeManageAliasesModalBtn) { | |
closeManageAliasesModalBtn.addEventListener('click', () => { | |
manageAliasesModal.style.display = 'none'; | |
}); | |
} | |
// --- COMPLETE Manage Autoreply (OOF) Modal Logic --- | |
const manageAutoreplyModal = document.getElementById('manageAutoreplyModal'); | |
const manageEditAutoreplyBtn = document.getElementById('manageEditAutoreplyBtn'); // Button from main manage modal | |
const closeAutoreplyModalBtn = document.getElementById('closeAutoreplyModalBtn'); | |
const saveAutoreplySettingsBtn = document.getElementById('saveAutoreplySettingsBtn'); | |
const manageAutoreplyUserUpnSpan = document.getElementById('manageAutoreplyUserUpn'); | |
const manageAutoreplyStatusContainer = document.getElementById('manageAutoreplyStatusContainer'); | |
const manageAutoreplyStatusMessage = document.getElementById('manageAutoreplyStatusMessage'); | |
const oofStateRadios = document.getElementsByName('oofState'); | |
const oofScheduledSettingsDiv = document.getElementById('oofScheduledSettings'); | |
const oofStartTimeInput = document.getElementById('oofStartTime'); | |
const oofEndTimeInput = document.getElementById('oofEndTime'); | |
const oofExternalAudienceSelect = document.getElementById('oofExternalAudience'); | |
const oofInternalReplyTextarea = document.getElementById('oofInternalReply'); | |
const oofExternalReplyTextarea = document.getElementById('oofExternalReply'); | |
let originalOofSettings = {}; // Store initial settings for comparison | |
function resetAutoreplyForm() { | |
document.getElementById('oofStateDisabled').checked = true; | |
oofScheduledSettingsDiv.style.display = 'none'; | |
oofStartTimeInput.value = ''; | |
oofEndTimeInput.value = ''; | |
oofExternalAudienceSelect.value = 'None'; | |
oofInternalReplyTextarea.value = ''; | |
oofExternalReplyTextarea.value = ''; | |
originalOofSettings = {}; | |
} | |
// Show/hide scheduled settings based on radio button selection | |
oofStateRadios.forEach(radio => { | |
radio.addEventListener('change', () => { | |
if (document.getElementById('oofStateScheduled').checked) { | |
oofScheduledSettingsDiv.style.display = 'block'; | |
} else { | |
oofScheduledSettingsDiv.style.display = 'none'; | |
} | |
}); | |
}); | |
// Listener to OPEN the Manage Autoreply modal | |
if (manageEditAutoreplyBtn) { | |
manageEditAutoreplyBtn.addEventListener('click', () => { | |
const upnFromManageModal = document.getElementById('manageModalUserUpn').textContent; | |
resetAutoreplyForm(); | |
manageAutoreplyStatusContainer.style.display = 'none'; | |
if (upnFromManageModal && !upnFromManageModal.startsWith('[')) { | |
// USER IS LOADED | |
manageAutoreplyUserUpnSpan.textContent = upnFromManageModal; | |
saveAutoreplySettingsBtn.disabled = false; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
manageAutoreplyStatusMessage.textContent = 'Loading autoreply settings for ' + upnFromManageModal + '...'; | |
fetch('/getoofsettings?upn=' + encodeURIComponent(upnFromManageModal)) | |
.then(response => { | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'HTTP Error ' + response.status); }); | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
originalOofSettings = data; // Store original settings | |
document.querySelector('input[name="oofState"][value="' + data.autoReplyState + '"]').checked = true; | |
if (data.autoReplyState === 'Scheduled') { | |
oofScheduledSettingsDiv.style.display = 'block'; | |
oofStartTimeInput.value = data.startTime ? data.startTime.slice(0, 16) : ''; // Format for datetime-local | |
oofEndTimeInput.value = data.endTime ? data.endTime.slice(0, 16) : ''; // Format for datetime-local | |
} else { | |
oofScheduledSettingsDiv.style.display = 'none'; | |
} | |
oofExternalAudienceSelect.value = data.externalAudience || 'None'; | |
oofInternalReplyTextarea.value = data.internalMessage || ''; | |
oofExternalReplyTextarea.value = data.externalMessage || ''; | |
manageAutoreplyStatusContainer.style.display = 'none'; | |
}) | |
.catch(error => { | |
manageAutoreplyStatusMessage.textContent = 'Failed to load OOF settings: ' + error.message; | |
saveAutoreplySettingsBtn.disabled = true; // Disable save if current settings can't be loaded | |
}); | |
} else { | |
// NO USER LOADED | |
manageAutoreplyUserUpnSpan.textContent = '[No User Loaded]'; | |
saveAutoreplySettingsBtn.disabled = true; | |
} | |
manageAutoreplyModal.style.display = 'block'; | |
}); | |
} | |
// Listener to SAVE Manage Autoreply changes | |
if (saveAutoreplySettingsBtn) { | |
saveAutoreplySettingsBtn.addEventListener('click', () => { | |
const upn = manageAutoreplyUserUpnSpan.textContent; | |
if (!upn || upn.startsWith('[')) { | |
manageAutoreplyStatusMessage.textContent = 'No user loaded to save settings for.'; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
return; | |
} | |
const selectedStateRadio = document.querySelector('input[name="oofState"]:checked'); | |
const settingsToSave = { | |
upn: upn, | |
autoReplyState: selectedStateRadio ? selectedStateRadio.value : 'Disabled', | |
externalAudience: oofExternalAudienceSelect.value, | |
internalMessage: oofInternalReplyTextarea.value, | |
externalMessage: oofExternalReplyTextarea.value, | |
startTime: null, | |
endTime: null | |
}; | |
if (settingsToSave.autoReplyState === 'Scheduled') { | |
if (!oofStartTimeInput.value || !oofEndTimeInput.value) { | |
manageAutoreplyStatusMessage.textContent = 'Start and End times are required for scheduled autoreplies.'; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
return; | |
} | |
const startDt = new Date(oofStartTimeInput.value); | |
const endDt = new Date(oofEndTimeInput.value); | |
if (startDt >= endDt) { | |
manageAutoreplyStatusMessage.textContent = 'Scheduled End Time must be after Start Time.'; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
return; | |
} | |
settingsToSave.startTime = startDt.toISOString(); | |
settingsToSave.endTime = endDt.toISOString(); | |
} | |
// Change detection | |
let changed = false; | |
if (settingsToSave.autoReplyState !== originalOofSettings.autoReplyState || | |
settingsToSave.externalAudience !== originalOofSettings.externalAudience || | |
settingsToSave.internalMessage !== originalOofSettings.internalMessage || | |
settingsToSave.externalMessage !== originalOofSettings.externalMessage || | |
(settingsToSave.autoReplyState === 'Scheduled' && | |
(settingsToSave.startTime !== (originalOofSettings.startTime || null) || // Compare with null if original was not set | |
settingsToSave.endTime !== (originalOofSettings.endTime || null) ))) { | |
changed = true; | |
} | |
// If state changed from/to Scheduled, and times were cleared/set | |
if (originalOofSettings.autoReplyState === 'Scheduled' && settingsToSave.autoReplyState !== 'Scheduled') changed = true; | |
if (originalOofSettings.autoReplyState !== 'Scheduled' && settingsToSave.autoReplyState === 'Scheduled') changed = true; | |
if (!changed) { | |
manageAutoreplyStatusMessage.textContent = 'No changes detected.'; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
return; | |
} | |
let confirmMsg = 'Are you sure you want to apply these Autoreply settings for ' + upn + '?\n\n'; | |
confirmMsg += 'State: ' + settingsToSave.autoReplyState + '\n'; | |
if (settingsToSave.autoReplyState === 'Scheduled') { | |
confirmMsg += 'Start: ' + new Date(settingsToSave.startTime).toLocaleString() + '\n'; | |
confirmMsg += 'End: ' + new Date(settingsToSave.endTime).toLocaleString() + '\n'; | |
} | |
confirmMsg += 'External Audience: ' + settingsToSave.externalAudience + '\n'; | |
confirmMsg += 'Internal Reply: ' + (settingsToSave.internalMessage.substring(0, 30) || '[empty]') + '...\n'; | |
confirmMsg += 'External Reply: ' + (settingsToSave.externalMessage.substring(0, 30) || '[empty]') + '...\n'; | |
if (confirm(confirmMsg)) { | |
manageAutoreplyStatusMessage.textContent = 'Saving autoreply settings...'; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
fetch('/setoofsettings', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(settingsToSave) | |
}) | |
.then(response => { | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'HTTP Error ' + response.status); }); | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
manageAutoreplyModal.style.display = 'none'; | |
// Optionally refresh main dashboard view if it shows OOF status | |
if (document.getElementById('loadUserBtn') && document.getElementById('usersearch').value === upn) { | |
document.getElementById('loadUserBtn').click(); | |
} | |
}) | |
.catch(error => { | |
manageAutoreplyStatusMessage.textContent = 'Save failed: ' + error.message; | |
}); | |
} else { | |
manageAutoreplyStatusMessage.textContent = 'Save cancelled.'; | |
manageAutoreplyStatusContainer.style.display = 'block'; | |
} | |
}); | |
} | |
// Listener to CLOSE the Manage Autoreply modal | |
if (closeAutoreplyModalBtn) { | |
closeAutoreplyModalBtn.addEventListener('click', () => { | |
manageAutoreplyModal.style.display = 'none'; | |
}); | |
} | |
// --- COMPLETE Manage Teams Phone Number Modal Logic --- | |
const manageTeamsPhoneModal = document.getElementById('manageTeamsPhoneModal'); | |
const manageEditTeamsPhoneBtn = document.getElementById('manageEditTeamsPhoneBtn'); | |
const closeTeamsPhoneModalBtn = document.getElementById('closeTeamsPhoneModalBtn'); | |
const saveTeamsPhoneChangesBtn = document.getElementById('saveTeamsPhoneChangesBtn'); | |
const teamsPhoneUserUpnSpan = document.getElementById('teamsPhoneUserUpn'); | |
const teamsPhoneStatusContainer = document.getElementById('teamsPhoneStatusContainer'); | |
const teamsPhoneStatusMessage = document.getElementById('teamsPhoneStatusMessage'); | |
// Current Settings Display | |
const currentTeamsLineUriInput = document.getElementById('currentTeamsLineUri'); | |
const currentTeamsNumberTypeInput = document.getElementById('currentTeamsNumberType'); | |
const currentTeamsEVEnabledInput = document.getElementById('currentTeamsEVEnabled'); | |
const currentTeamsCallingPolicyInput = document.getElementById('currentTeamsCallingPolicy'); | |
const currentTeamsUpgradePolicyInput = document.getElementById('currentTeamsUpgradePolicy'); | |
const currentTeamsTenantDialPlanInput = document.getElementById('currentTeamsTenantDialPlan'); | |
const currentTeamsVoiceRoutingPolicyInput = document.getElementById('currentTeamsVoiceRoutingPolicy'); | |
// Assign/Modify Number Elements | |
const assignTeamsPhoneNumberInput = document.getElementById('assignTeamsPhoneNumberInput'); | |
const removeTeamsNumberBtn = document.getElementById('removeTeamsNumberBtn'); | |
const teamsEmergencyLocationSelect = document.getElementById('teamsEmergencyLocationSelect'); | |
const availableTeamsNumbersList = document.getElementById('availableTeamsNumbersList'); | |
const findAvailableTeamsNumbersBtn = document.getElementById('findAvailableTeamsNumbersBtn'); | |
const useSelectedTeamsNumberBtn = document.getElementById('useSelectedTeamsNumberBtn'); | |
// Modify Policies & Settings Elements | |
const chkTeamsEVEnabled = document.getElementById('chkTeamsEVEnabled'); | |
const selectTeamsCallingPolicy = document.getElementById('selectTeamsCallingPolicy'); | |
const selectTeamsUpgradePolicy = document.getElementById('selectTeamsUpgradePolicy'); | |
const selectTeamsTenantDialPlan = document.getElementById('selectTeamsTenantDialPlan'); | |
const selectTeamsVoiceRoutingPolicy = document.getElementById('selectTeamsVoiceRoutingPolicy'); | |
let originalTeamsPhoneSettings = {}; // Store initial settings for comparison | |
function resetTeamsPhoneModalForm() { | |
// Get references to all elements within this function for safety | |
const statusContainer = document.getElementById('teamsPhoneStatusContainer'); | |
const statusMessage = document.getElementById('teamsPhoneStatusMessage'); | |
const currentSettingsInputs = [ | |
document.getElementById('currentTeamsLineUri'), | |
document.getElementById('currentTeamsNumberType'), | |
document.getElementById('currentTeamsEVEnabled'), | |
document.getElementById('currentTeamsCallingPolicy'), | |
document.getElementById('currentTeamsUpgradePolicy'), | |
document.getElementById('currentTeamsTenantDialPlan'), | |
document.getElementById('currentTeamsVoiceRoutingPolicy') | |
]; | |
const assignPhoneInput = document.getElementById('assignTeamsPhoneNumberInput'); | |
const locationSelect = document.getElementById('teamsEmergencyLocationSelect'); | |
const availableNumbersList = document.getElementById('availableTeamsNumbersList'); | |
const evEnabledCheckbox = document.getElementById('chkTeamsEVEnabled'); | |
const policySelects = [ | |
document.getElementById('selectTeamsCallingPolicy'), | |
document.getElementById('selectTeamsUpgradePolicy'), | |
document.getElementById('selectTeamsTenantDialPlan'), | |
document.getElementById('selectTeamsVoiceRoutingPolicy') | |
]; | |
const saveButton = document.getElementById('saveTeamsPhoneChangesBtn'); | |
// Reset status strip | |
if (statusContainer) statusContainer.style.display = 'none'; | |
if (statusMessage) statusMessage.textContent = ''; | |
// Clear "Current Settings" display fields | |
currentSettingsInputs.forEach(input => { | |
if (input) input.value = ''; | |
}); | |
// Reset editable fields to their default state | |
if (assignPhoneInput) assignPhoneInput.value = ''; | |
if (locationSelect) locationSelect.innerHTML = '<option value="">(Not Loaded)</option>'; | |
if (availableNumbersList) availableNumbersList.innerHTML = ''; | |
// Set "Enterprise Voice Enabled" to checked by default | |
if (evEnabledCheckbox) { | |
evEnabledCheckbox.checked = true; | |
} | |
// Reset policy dropdowns to "(No Change)" | |
policySelects.forEach(sel => { | |
if (sel) { | |
// Recreate the default options in case they were cleared | |
sel.innerHTML = '<option value="">(No Change)</option>'; | |
// Add the special value for unassigning a policy | |
sel.add(new Option('(Global/Org-Default)', '_org-default_')); | |
} | |
}); | |
// Reset the data cache and disable the save button | |
window.originalTeamsPhoneSettings = {}; | |
if (saveButton) saveButton.disabled = true; | |
} | |
function populateDropdown(selectElement, items, currentValue, addNoChangeOption = true, addGlobalOption = true) { | |
selectElement.innerHTML = ''; // Clear existing | |
if (addNoChangeOption) selectElement.add(new Option('(No Change)', '')); | |
if (addGlobalOption) selectElement.add(new Option('(Global/Org-Default)', '_org-default_')); | |
if (items && items.length > 0) { | |
items.forEach(item => { | |
// Assuming item is a string (policy name) or an object {id: '', name: ''} for locations | |
let text, val; | |
if (typeof item === 'string') { | |
text = item; | |
val = item; | |
} else { // For location objects | |
text = item.name; | |
val = item.id; | |
} | |
const option = new Option(text, val); | |
if (currentValue && val === currentValue) { | |
option.selected = true; | |
} | |
selectElement.add(option); | |
}); | |
} | |
// If currentValue was set but not found in items, select the "(No Change)" option if it exists | |
if (currentValue && !selectElement.value && addNoChangeOption) { | |
selectElement.value = ''; | |
} | |
} | |
// Listener to OPEN the Manage Teams modal | |
if (manageEditTeamsPhoneBtn) { | |
manageEditTeamsPhoneBtn.addEventListener('click', () => { | |
const upnFromManageModal = document.getElementById('manageModalUserUpn').textContent; | |
resetTeamsPhoneModalForm(); | |
teamsPhoneStatusContainer.style.display = 'none'; | |
// Initial fetch for dropdowns (locations, policies) - happens regardless of user | |
const locationsPromise = fetch('/getteamslislocations').then(res => res.json()).then(data => { | |
if (data.error) throw new Error('LIS Locations: ' + data.error); | |
populateDropdown(teamsEmergencyLocationSelect, data.locations || [], '', true, false); // No "Global" for locations | |
return data; | |
}); | |
const policiesPromise = fetch('/getteamspolicies').then(res => res.json()).then(data => { | |
if (data.error) throw new Error('Policies: ' + data.error); | |
populateDropdown(selectTeamsCallingPolicy, data.callingPolicies || [], ''); | |
populateDropdown(selectTeamsUpgradePolicy, data.upgradePolicies || [], ''); | |
populateDropdown(selectTeamsTenantDialPlan, data.dialPlans || [], ''); | |
populateDropdown(selectTeamsVoiceRoutingPolicy, data.voiceRoutingPolicies || [], ''); | |
return data; | |
}); | |
Promise.all([locationsPromise, policiesPromise]).catch(error => { | |
teamsPhoneStatusContainer.style.display = 'block'; | |
teamsPhoneStatusMessage.textContent = 'Failed to load initial modal data: ' + error.message; | |
}); | |
if (upnFromManageModal && !upnFromManageModal.startsWith('[')) { | |
// USER IS LOADED | |
teamsPhoneUserUpnSpan.textContent = upnFromManageModal; | |
saveTeamsPhoneChangesBtn.disabled = false; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
teamsPhoneStatusMessage.textContent = 'Loading Teams settings for ' + upnFromManageModal + '...'; | |
fetch('/getuserteamsphonesettings?upn=' + encodeURIComponent(upnFromManageModal)) | |
.then(response => { | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'HTTP Error ' + response.status); }); | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
originalTeamsPhoneSettings = data; | |
currentTeamsLineUriInput.value = data.lineUri || ''; | |
currentTeamsNumberTypeInput.value = data.numberType || ''; | |
currentTeamsEVEnabledInput.value = data.enterpriseVoiceEnabled ? 'Yes' : 'No'; | |
currentTeamsCallingPolicyInput.value = data.teamsCallingPolicy || ''; | |
currentTeamsUpgradePolicyInput.value = data.teamsUpgradePolicy || ''; | |
currentTeamsTenantDialPlanInput.value = data.tenantDialPlan || ''; | |
currentTeamsVoiceRoutingPolicyInput.value = data.onlineVoiceRoutingPolicy || ''; | |
// Pre-fill editable fields | |
assignTeamsPhoneNumberInput.value = data.lineUri || ''; | |
chkTeamsEVEnabled.checked = data.enterpriseVoiceEnabled || false; | |
// For dropdowns, re-populate/select based on current user values AFTER policies are loaded | |
Promise.all([policiesPromise]).then(() => { // Ensure policiesPromise is resolved | |
populateDropdown(selectTeamsCallingPolicy, document.getElementById('selectTeamsCallingPolicy').options.length > 2 ? null : [], data.teamsCallingPolicy); | |
populateDropdown(selectTeamsUpgradePolicy, document.getElementById('selectTeamsUpgradePolicy').options.length > 2 ? null : [], data.teamsUpgradePolicy); | |
populateDropdown(selectTeamsTenantDialPlan, document.getElementById('selectTeamsTenantDialPlan').options.length > 2 ? null : [], data.tenantDialPlan); | |
populateDropdown(selectTeamsVoiceRoutingPolicy, document.getElementById('selectTeamsVoiceRoutingPolicy').options.length > 2 ? null : [], data.onlineVoiceRoutingPolicy); | |
// Note: For LIS locations, user's current assigned LIS is not typically fetched directly with Get-CsOnlineUser, it's part of number assignment. | |
// teamsEmergencyLocationSelect won't be pre-selected with user's current LIS unless /getuserteamsphonesettings returns it. | |
}); | |
teamsPhoneStatusContainer.style.display = 'none'; | |
}) | |
.catch(error => { | |
teamsPhoneStatusMessage.textContent = 'Failed to load Teams settings: ' + error.message; | |
saveTeamsPhoneChangesBtn.disabled = true; | |
}); | |
} else { | |
// NO USER LOADED | |
teamsPhoneUserUpnSpan.textContent = '[No User Loaded]'; | |
saveTeamsPhoneChangesBtn.disabled = true; | |
// Dropdowns (locations, policies) will still load from promises above. | |
} | |
manageTeamsPhoneModal.style.display = 'block'; | |
}); | |
} | |
// Find Available Numbers | |
findAvailableTeamsNumbersBtn?.addEventListener('click', () => { | |
const locationId = teamsEmergencyLocationSelect.value; | |
teamsPhoneStatusMessage.textContent = 'Finding available numbers...'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
availableTeamsNumbersList.innerHTML = '<option>Loading...</option>'; | |
fetch('/getavailableteamsnumbers' + (locationId ? '?locationId=' + encodeURIComponent(locationId) : '')) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
availableTeamsNumbersList.innerHTML = ''; | |
if (data.numbers && data.numbers.length > 0) { | |
data.numbers.forEach(num => availableTeamsNumbersList.add(new Option(num, num))); | |
teamsPhoneStatusMessage.textContent = data.numbers.length + ' available number(s) found.'; | |
} else { | |
availableTeamsNumbersList.add(new Option('No numbers found for criteria', '')); | |
teamsPhoneStatusMessage.textContent = 'No available numbers found.'; | |
} | |
}) | |
.catch(error => { | |
teamsPhoneStatusMessage.textContent = 'Failed to find numbers: ' + error.message; | |
availableTeamsNumbersList.innerHTML = '<option>Error loading numbers</option>'; | |
}); | |
}); | |
// Use Selected Number | |
useSelectedTeamsNumberBtn?.addEventListener('click', () => { | |
if (availableTeamsNumbersList.selectedIndex !== -1) { | |
assignTeamsPhoneNumberInput.value = availableTeamsNumbersList.value; | |
} else { | |
teamsPhoneStatusMessage.textContent = 'Please select a number from the list first.'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
} | |
}); | |
// Clear/Unassign Number | |
removeTeamsNumberBtn?.addEventListener('click', () => { | |
assignTeamsPhoneNumberInput.value = ''; // Clearing the input signifies unassignment on save | |
teamsPhoneStatusMessage.textContent = 'Phone number will be unassigned on save.'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
}); | |
// Save Manage Teams Changes | |
if (saveTeamsPhoneChangesBtn) { | |
saveTeamsPhoneChangesBtn.addEventListener('click', () => { | |
const upn = teamsPhoneUserUpnSpan.textContent; | |
if (!upn || upn.startsWith('[')) { | |
teamsPhoneStatusMessage.textContent = 'No user loaded to save settings for.'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
return; | |
} | |
const changes = {}; | |
let changeDetected = false; | |
// Phone Number | |
const newPhoneNumber = assignTeamsPhoneNumberInput.value.trim(); | |
if (newPhoneNumber !== (originalTeamsPhoneSettings.lineUri || '')) { | |
changes.phoneNumber = newPhoneNumber; // Empty string means unassign | |
changeDetected = true; | |
} | |
// Emergency Location (only if new number is assigned and location is selected) | |
if (changes.phoneNumber && changes.phoneNumber !== '' && teamsEmergencyLocationSelect.value) { | |
changes.locationId = teamsEmergencyLocationSelect.value; | |
// Note: Original location ID isn't typically part of originalTeamsPhoneSettings directly, | |
// Any selection here is considered a change if a number is being assigned/changed. | |
changeDetected = true; | |
} | |
// Enterprise Voice Enabled | |
const newEVEnabled = chkTeamsEVEnabled.checked; | |
if (newEVEnabled !== (originalTeamsPhoneSettings.enterpriseVoiceEnabled || false)) { | |
changes.enterpriseVoiceEnabled = newEVEnabled; | |
changeDetected = true; | |
} | |
// Policies | |
const policies = [ | |
{ select: selectTeamsCallingPolicy, key: 'teamsCallingPolicy', original: originalTeamsPhoneSettings.teamsCallingPolicy }, | |
{ select: selectTeamsUpgradePolicy, key: 'teamsUpgradePolicy', original: originalTeamsPhoneSettings.teamsUpgradePolicy }, | |
{ select: selectTeamsTenantDialPlan, key: 'tenantDialPlan', original: originalTeamsPhoneSettings.tenantDialPlan }, | |
{ select: selectTeamsVoiceRoutingPolicy, key: 'onlineVoiceRoutingPolicy', original: originalTeamsPhoneSettings.onlineVoiceRoutingPolicy } | |
]; | |
policies.forEach(p => { | |
if (p.select.value !== '') { // Only consider if "(No Change)" is not selected | |
if (p.select.value !== (p.original || '')) { // Compare with original or empty string if original was null | |
changes[p.key] = p.select.value === '_org-default_' ? null : p.select.value; // Send null to revert to global default | |
changeDetected = true; | |
} | |
} | |
}); | |
if (!changeDetected) { | |
teamsPhoneStatusMessage.textContent = 'No changes detected to save.'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
return; | |
} | |
let confirmMsg = 'You are about to apply the following Teams Telephony changes for ' + upn + ':\n\n'; | |
if (changes.hasOwnProperty('phoneNumber')) { | |
confirmMsg += (changes.phoneNumber === '' ? 'Unassign Phone Number' : 'Set Phone Number to: ' + changes.phoneNumber) + '\n'; | |
if(changes.locationId) confirmMsg += ' with Location ID: ' + changes.locationId + '\n'; | |
} | |
if (changes.hasOwnProperty('enterpriseVoiceEnabled')) { | |
confirmMsg += 'Set Enterprise Voice Enabled: ' + changes.enterpriseVoiceEnabled + '\n'; | |
} | |
['teamsCallingPolicy', 'teamsUpgradePolicy', 'tenantDialPlan', 'onlineVoiceRoutingPolicy'].forEach(key => { | |
if (changes.hasOwnProperty(key)) { | |
confirmMsg += 'Set ' + key + ': ' + (changes[key] === null ? '(Global/Org-Default)' : changes[key]) + '\n'; | |
} | |
}); | |
confirmMsg += '\nProceed?'; | |
if (confirm(confirmMsg)) { | |
const payload = { upn: upn, changes: changes }; | |
teamsPhoneStatusMessage.textContent = 'Saving Teams phone settings...'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
fetch('/updateuserteamsphonesettings', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
manageTeamsPhoneModal.style.display = 'none'; | |
if (document.getElementById('loadUserBtn')) document.getElementById('loadUserBtn').click(); | |
}) | |
.catch(error => { | |
teamsPhoneStatusMessage.textContent = 'Save failed: ' + error.message; | |
}); | |
} else { | |
teamsPhoneStatusMessage.textContent = 'Save cancelled.'; | |
teamsPhoneStatusContainer.style.display = 'block'; | |
} | |
}); | |
} | |
// Close Manage Teams modal | |
if (closeTeamsPhoneModalBtn) { | |
closeTeamsPhoneModalBtn.addEventListener('click', () => { | |
manageTeamsPhoneModal.style.display = 'none'; | |
}); | |
} | |
// --- Offboarding Tab UI Logic --- | |
const offboardStandardBtn = document.getElementById('offboardStandardBtn'); | |
const offboardSelectAllBtn = document.getElementById('offboardSelectAllBtn'); | |
const offboardUnselectAllBtn = document.getElementById('offboardUnselectAllBtn'); | |
const offboardingActionGroups = document.querySelectorAll('#manageCatOffboardingContent .offboarding-action-group'); | |
// Function to toggle visibility of action-details sections | |
function toggleOffboardActionDetails(checkboxElement) { | |
const detailsDiv = checkboxElement.closest('.offboarding-action-group')?.querySelector('.action-details'); | |
if (detailsDiv) { | |
detailsDiv.style.display = checkboxElement.checked ? 'block' : 'none'; | |
} | |
} | |
// Event listeners to checkboxes within action groups to toggle their details sections | |
offboardingActionGroups.forEach(group => { | |
const chk = group.querySelector('input[type="checkbox"]'); | |
if (chk) { | |
// Initial hide/show based on page load state (if any were pre-checked by mistake) | |
toggleOffboardActionDetails(chk); | |
// Change listener | |
chk.addEventListener('change', (event) => { | |
toggleOffboardActionDetails(event.target); | |
}); | |
} | |
}); | |
if (offboardStandardBtn) { | |
offboardStandardBtn.addEventListener('click', () => { | |
offboardingActionGroups.forEach(group => { | |
const chk = group.querySelector('input[type="checkbox"]'); | |
if (chk) { | |
if (chk.dataset.standard === 'true') { | |
chk.checked = true; | |
} else { | |
chk.checked = false; // Unselect non-standard ones | |
} | |
toggleOffboardActionDetails(chk); // Update visibility | |
} | |
}); | |
updateStatusMessage('Standard offboarding tasks selected.', 'info'); | |
}); | |
} | |
if (offboardSelectAllBtn) { | |
offboardSelectAllBtn.addEventListener('click', () => { | |
offboardingActionGroups.forEach(group => { | |
const chk = group.querySelector('input[type="checkbox"]'); | |
if (chk) { | |
chk.checked = true; | |
toggleOffboardActionDetails(chk); | |
} | |
}); | |
updateStatusMessage('All offboarding tasks selected.', 'info'); | |
}); | |
} | |
if (offboardUnselectAllBtn) { | |
offboardUnselectAllBtn.addEventListener('click', () => { | |
offboardingActionGroups.forEach(group => { | |
const chk = group.querySelector('input[type="checkbox"]'); | |
if (chk) { | |
chk.checked = false; | |
toggleOffboardActionDetails(chk); | |
} | |
}); | |
updateStatusMessage('All offboarding tasks unselected.', 'info'); | |
}); | |
} | |
// Picker for Offboarding Forwarding User | |
const offboardSearchForwardUserBtn = document.getElementById('offboardSearchForwardUserBtn'); // ID from your original HTML | |
const offboardForwardToUpnInput = document.getElementById('offboardForwardToUpn'); // ID from your original HTML | |
if (offboardSearchForwardUserBtn && offboardForwardToUpnInput) { | |
offboardSearchForwardUserBtn.addEventListener('click', () => { | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); // Status inside Manage Modal | |
const query = prompt("Search for a user/mailbox to forward to:"); | |
if (!query) return; | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Searching for user...'; | |
else updateStatusMessage('Searching for user...', 'info'); | |
fetch('/search?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
if (results.error) throw new Error(results.error); | |
if (results.length === 0) { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'No users found for that query.'; | |
else updateStatusMessage('No users found.', 'info'); | |
return; | |
} | |
let msg = "Select a user to forward to:\n" + results.map((u, i) => (i + 1) + ". " + u.displayName + " (" + u.upn + ")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
offboardForwardToUpnInput.value = results[choice - 1].upn; | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Forwarding recipient selected: ' + results[choice - 1].upn; | |
else updateStatusMessage('Forwarding recipient selected.', 'success'); | |
} else { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Search cancelled or invalid selection.'; | |
} | |
}) | |
.catch(err => { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'User search failed: ' + err.message; | |
else updateStatusMessage('User search failed: ' + err.message, 'error'); | |
}); | |
}); | |
} | |
// Picker for Offboarding Delegate Access Users | |
const offboardPickDelegateBtn = document.getElementById('offboardPickDelegateBtn'); // New ID from HTML change | |
const offboardDelegateToUpnTextarea = document.getElementById('offboardDelegateToUpnTextarea'); // New ID | |
const offboardClearDelegatesBtn = document.getElementById('offboardClearDelegatesBtn'); // New ID | |
if (offboardPickDelegateBtn && offboardDelegateToUpnTextarea) { | |
offboardPickDelegateBtn.addEventListener('click', () => { | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const currentDelegates = offboardDelegateToUpnTextarea.value.split('\n').filter(u => u.trim() !== ''); | |
if (currentDelegates.length >= 3) { | |
alert('You can add a maximum of 3 delegates to the list.'); | |
return; | |
} | |
const query = prompt("Search for a user to delegate Full Access to:"); | |
if (!query) return; | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Searching for delegate...'; | |
else updateStatusMessage('Searching for delegate...', 'info'); | |
fetch('/search?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
if (results.error) throw new Error(results.error); | |
if (results.length === 0) { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'No users found for that query.'; | |
else updateStatusMessage('No users found.', 'info'); | |
return; | |
} | |
let msg = "Select a delegate:\n" + results.map((u, i) => (i + 1) + ". " + u.displayName + " (" + u.upn + ")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedUpn = results[choice - 1].upn; | |
if (!currentDelegates.includes(selectedUpn)) { | |
offboardDelegateToUpnTextarea.value += (currentDelegates.length > 0 ? '\n' : '') + selectedUpn; | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Delegate ' + selectedUpn + ' added to list.'; | |
else updateStatusMessage('Delegate added to list.', 'success'); | |
} else { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Delegate ' + selectedUpn + ' is already in the list.'; | |
else updateStatusMessage('Delegate already in list.', 'info'); | |
} | |
} else { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Search cancelled or invalid selection.'; | |
} | |
}) | |
.catch(err => { | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Delegate search failed: ' + err.message; | |
else updateStatusMessage('Delegate search failed: ' + err.message, 'error'); | |
}); | |
}); | |
} | |
if (offboardClearDelegatesBtn && offboardDelegateToUpnTextarea) { | |
offboardClearDelegatesBtn.addEventListener('click', () => { | |
offboardDelegateToUpnTextarea.value = ''; | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
if(currentModalStatusMsg) currentModalStatusMsg.textContent = 'Delegate list cleared.'; | |
else updateStatusMessage('Delegate list cleared.', 'info'); | |
}); | |
} | |
// --- "Run Selected Offboarding Tasks" Logic --- | |
const runOffboardingTasksBtn = document.getElementById('runOffboardingTasksBtn'); | |
if (runOffboardingTasksBtn) { | |
runOffboardingTasksBtn.addEventListener('click', async (event) => { | |
event.preventDefault(); | |
const targetUpn = document.getElementById('manageModalUserUpn').textContent; | |
const offboardStatusContainer = document.getElementById('manageModalStatusContainer'); // Assuming use of main manage modal status | |
const offboardStatusMessage = document.getElementById('manageModalStatusMessage'); | |
if (!targetUpn || targetUpn.startsWith('[')) { | |
updateStatusMessage("Please load a user in the main dashboard first.", 'error'); | |
if(offboardStatusContainer) offboardStatusContainer.style.display = 'block'; | |
return; | |
} | |
// --- 1. Collect Selected Tasks and Parameters --- | |
const tasksToExecute = []; | |
const taskOrder = [ // Define order: Mailbox-related first | |
// Mailbox sensitive actions | |
'offboardConvertToShared', 'offboardHideFromGAL', 'offboardSetupAutoreply', | |
'offboardSetupForwarding', 'offboardDelegateAccess', | |
// Account/Access/License actions | |
'offboardBlockSignIn', 'offboardResetPassword', 'offboardRevokeSessions', 'offboardRevokeMfa', | |
'offboardRemoveManager', 'offboardRemoveGroups', 'offboardRemoveLicenses', | |
'offboardRemoveDDI', 'offboardRemoveDevices', 'offboardAddNotes' | |
]; | |
document.querySelectorAll('#manageCatOffboardingContent .offboarding-action-group > input[type="checkbox"]:checked').forEach(chk => { | |
const task = { id: chk.id, name: chk.nextElementSibling.textContent.trim(), params: {} }; | |
const detailsDiv = chk.closest('.offboarding-action-group').querySelector('.action-details'); | |
if (detailsDiv) { | |
switch (task.id) { | |
case 'offboardSetupAutoreply': | |
task.params.internalReply = detailsDiv.querySelector('#offboardInternalReply')?.value; | |
task.params.externalReply = detailsDiv.querySelector('#offboardExternalReply')?.value; | |
task.params.audience = detailsDiv.querySelector('#offboardOofAudience')?.value; | |
break; | |
case 'offboardSetupForwarding': | |
task.params.forwardToUpn = detailsDiv.querySelector('#offboardForwardToUpn')?.value; | |
task.params.deliverAndForward = detailsDiv.querySelector('#offboardDeliverAndForward')?.checked; | |
break; | |
case 'offboardDelegateAccess': | |
// Assuming Full Access for all delegates from the textarea | |
const delegatesText = detailsDiv.querySelector('#offboardDelegateToUpnTextarea')?.value; | |
task.params.delegates = delegatesText ? delegatesText.split('\n').filter(u => u.trim() !== '') : []; | |
task.params.permissionType = 'FullAccess'; // Hardcoded for now based on label | |
task.params.automap = true; // Default automapping to true for offboarding scenario | |
break; | |
case 'offboardAddNotes': | |
task.params.notesText = detailsDiv.querySelector('#offboardNotesText')?.value; | |
break; | |
case 'offboardRemoveDevices': | |
const mobileOnlyChk = detailsDiv.querySelector('#offboardRemoveMobileOnly'); | |
if (mobileOnlyChk) { | |
task.params.mobileOnly = mobileOnlyChk.checked; | |
} | |
break; | |
} | |
} | |
tasksToExecute.push(task); | |
}); | |
// Sort tasks according to predefined order | |
tasksToExecute.sort((a, b) => { | |
let indexA = taskOrder.indexOf(a.id); | |
let indexB = taskOrder.indexOf(b.id); | |
if (indexA === -1) indexA = taskOrder.length; // Unordered tasks go last | |
if (indexB === -1) indexB = taskOrder.length; | |
return indexA - indexB; | |
}); | |
if (tasksToExecute.length === 0) { | |
updateStatusMessage("No offboarding tasks selected.", 'info'); | |
if(offboardStatusContainer) offboardStatusContainer.style.display = 'block'; | |
return; | |
} | |
// --- 2. Confirmation --- | |
let confirmMsg = 'You are about to perform the following offboarding tasks for ' + targetUpn + ':\n\n'; | |
tasksToExecute.forEach(task => { confirmMsg += ' - ' + task.name + '\n'; }); | |
confirmMsg += '\nThis will include a user data backup first.\nProceed?'; | |
if (!confirm(confirmMsg)) { | |
updateStatusMessage("Offboarding process cancelled by user.", 'info'); | |
if(offboardStatusContainer) offboardStatusContainer.style.display = 'block'; | |
return; | |
} | |
// --- 3. Backup User Details First --- | |
if(offboardStatusContainer) offboardStatusContainer.style.display = 'block'; | |
updateStatusMessage('Step 1: Backing up user details for ' + targetUpn + '...', 'info'); | |
try { | |
const backupResponse = await fetch('/backupuser?upn=' + encodeURIComponent(targetUpn)); | |
const backupText = await backupResponse.text(); | |
if (!backupResponse.ok) { | |
updateStatusMessage('Backup failed: ' + backupText + '. Continuing with offboarding...', 'error'); | |
if (!confirm('Backup failed. Do you still want to proceed with offboarding tasks?')) { | |
updateStatusMessage('Offboarding cancelled due to backup failure.', 'info'); | |
return; | |
} | |
} else { | |
updateStatusMessage('Step 1: Backup successful. ' + backupText, 'success'); | |
} | |
} catch (error) { | |
updateStatusMessage('Backup step failed: ' + error.message + '. Continuing with offboarding...', 'error'); | |
if (!confirm('Backup step failed. Do you still want to proceed with offboarding tasks?')) { | |
updateStatusMessage('Offboarding cancelled due to backup failure.', 'info'); | |
return; | |
} | |
} | |
// --- 4. Sequential Task Execution --- | |
let overallSuccess = true; | |
const summaryLog = ['Offboarding Process for ' + targetUpn + ':']; | |
for (let i = 0; i < tasksToExecute.length; i++) { | |
const task = tasksToExecute[i]; | |
updateStatusMessage('Step ' + (i + 2) + '/' + (tasksToExecute.length + 1) + ': Processing - ' + task.name + '...', 'info'); | |
const payload = { | |
upn: targetUpn, | |
taskName: task.id, | |
params: task.params | |
}; | |
try { | |
const response = await fetch('/executeoffboardingtask', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
const result = await response.json(); | |
if (!response.ok || result.success === false) { // Check for HTTP error or success:false in JSON | |
const errorMsg = result.message || ('HTTP error ' + response.status); | |
updateStatusMessage('Step ' + (i + 2) + ': FAILED - ' + task.name + ': ' + errorMsg, 'error'); | |
summaryLog.push(' - ' + task.name + ': FAILED (' + errorMsg + ')'); | |
overallSuccess = false; | |
// Optionally, ask to continue or break here | |
// if (!confirm(task.name + " failed. Continue with next tasks?")) break; | |
} else { | |
updateStatusMessage('Step ' + (i + 2) + ': SUCCESS - ' + task.name, 'success'); | |
summaryLog.push(' - ' + task.name + ': Success'); | |
} | |
} catch (error) { | |
updateStatusMessage('Step ' + (i + 2) + ': ERROR - ' + task.name + ': ' + error.message, 'error'); | |
summaryLog.push(' - ' + task.name + ': ERROR (' + error.message + ')'); | |
overallSuccess = false; | |
} | |
await new Promise(resolve => setTimeout(resolve, 300)); // Small delay for readability of status | |
} | |
// --- 5. Final Summary --- | |
const finalSummaryMessage = overallSuccess ? "Offboarding process completed successfully." : "Offboarding process completed with one or more errors."; | |
updateStatusMessage(finalSummaryMessage, overallSuccess ? 'success' : 'error'); | |
summaryLog.push('\n' + finalSummaryMessage); | |
console.log(summaryLog.join('\n')); // Log detailed summary to console | |
alert(summaryLog.join('\n')); // Show summary in alert | |
// Optionally, refresh main user details | |
if (document.getElementById('loadUserBtn') && document.getElementById('usersearch').value === targetUpn) { | |
document.getElementById('loadUserBtn').click(); | |
} | |
}); | |
} | |
// --- COMPLETE ONBOARDING TAB UI & CREATE USER LOGIC ------------------------------------------------------------------------------------------------------------------------------------ | |
// Element References for Onboarding Tab & Sub-Modals | |
const onboardForm = document.getElementById('onboardingForm'); // Assuming your form has this ID | |
const onboardPickCopyUserBtn = document.getElementById('onboardPickCopyUserBtn'); | |
const onboardCopyUserDisplayInput = document.getElementById('onboardCopyUserDisplay'); | |
const onboardCopyUserGroupsTextarea = document.getElementById('onboardCopyUserGroupsDisplay'); | |
const onboardCopyUserLicensesTextarea = document.getElementById('onboardCopyUserLicensesDisplay'); | |
const onboardPickManagerBtn = document.getElementById('onboardPickManagerBtn'); | |
const onboardManagerDisplayInput = document.getElementById('onboardManagerDisplay'); | |
const onboardManagerDisplayHint = document.getElementById('onboardManagerDisplay_hint'); | |
const onboardAddGroupBtn = document.getElementById('onboardAddGroupBtn'); | |
const onboardSelectedGroupsTextarea = document.getElementById('onboardSelectedGroups'); | |
const onboardAddDelegationBtn = document.getElementById('onboardAddDelegationBtn'); | |
const onboardSelectedDelegationsTextarea = document.getElementById('onboardSelectedDelegations'); | |
const onboardPasswordInput = document.getElementById('onboardPassword'); | |
const onboardShowPasswordChk = document.getElementById('onboardShowPasswordChk'); | |
const onboardCopyPasswordBtn = document.getElementById('onboardCopyPasswordBtn'); | |
// License Picker Sub-Modal Elements | |
const pickLicenseSubModal = document.getElementById('pickLicenseSubModal'); | |
const onboardPickLicensesBtn = document.getElementById('onboardPickLicensesBtn'); | |
const confirmLicenseSelectionBtn = document.getElementById('confirmLicenseSelectionBtn'); | |
const cancelLicenseSelectionBtn = document.getElementById('cancelLicenseSelectionBtn'); | |
const pickableLicensesList = document.getElementById('pickableLicensesList'); | |
const filterPickableLicensesInput = document.getElementById('filterPickableLicenses'); | |
const onboardSelectedLicensesTextarea = document.getElementById('onboardSelectedLicensesDisplay'); | |
const onboardCreateUserBtn = document.getElementById('onboardCreateUserBtn'); | |
// Data holders | |
const onboardingHintFieldMap = { | |
'onboardJobTitle_hint': 'JobTitle', | |
'onboardDepartment_hint': 'Department', | |
'onboardCompany_hint': 'CompanyName', | |
'onboardManagerDisplay_hint': 'ManagerDisplayName', | |
'onboardOfficeLocation_hint': 'PhysicalDeliveryOfficeName', | |
'onboardStreetAddress_hint': 'StreetAddress', | |
'onboardCity_hint': 'City', | |
'onboardState_hint': 'State', | |
'onboardPostalCode_hint': 'PostalCode', | |
'onboardCountry_hint': 'Country', | |
'onboardMobilePhone_hint': 'MobilePhone', | |
'onboardBusinessPhone_hint': 'BusinessPhones', | |
'onboardUsageLocation_hint': 'UsageLocation' | |
}; | |
let allPickableTenantLicenses = []; // Cache for license picker filtering | |
let selectedLicensesForOnboarding = []; // Stores {skuId: 'guid', friendlyName: 'Name'} for licenses to be assigned to new user | |
// Function to clear onboarding form and hints | |
function clearOnboardingFormAndHints() { | |
console.log("DEBUG ONBOARD: Clearing onboarding form and hints."); | |
const fieldsToClear = [ | |
'onboardFirstName', 'onboardLastName', 'onboardDisplayName', 'onboardUsername', | |
'onboardJobTitle', 'onboardDepartment', 'onboardCompany', 'onboardManagerDisplay', | |
'onboardOfficeLocation', 'onboardStreetAddress', 'onboardCity', 'onboardState', | |
'onboardPostalCode', 'onboardCountry', 'onboardMobilePhone', 'onboardBusinessPhone', | |
'onboardUsageLocation', 'onboardPassword', 'onboardNotes', | |
'onboardSelectedGroups', 'onboardSelectedDelegations', 'onboardSelectedLicensesDisplay', | |
'onboardCopyUserDisplay', 'onboardCopyUserGroupsDisplay', 'onboardCopyUserLicensesDisplay' | |
]; | |
fieldsToClear.forEach(id => { | |
const el = document.getElementById(id); | |
if (el) el.value = ''; | |
}); | |
for (const hintId in onboardingHintFieldMap) { | |
const hintEl = document.getElementById(hintId); | |
if (hintEl) hintEl.textContent = ''; | |
} | |
selectedLicensesForOnboarding = []; // Clear selected licenses array | |
const domainDropdown = document.getElementById('onboardDomainDropdown'); | |
if (domainDropdown && domainDropdown.options.length > 0) domainDropdown.selectedIndex = 0; | |
if (onboardShowPasswordChk) onboardShowPasswordChk.checked = false; | |
if (onboardPasswordInput) onboardPasswordInput.type = 'password'; | |
} | |
// Event listener for the Onboarding Tab itself to clear the form | |
if (manageCatOnboardingBtn) { // manageCatOnboardingBtn is the button to SHOW the onboarding tab | |
manageCatOnboardingBtn.addEventListener('click', () => { | |
// This listener clears the form when the onboarding tab is activated. | |
clearOnboardingFormAndHints(); | |
populateOnboardingDomains(); | |
}); | |
} | |
// "Pick User to Copy Settings From" Button Logic | |
if (onboardPickCopyUserBtn) { | |
onboardPickCopyUserBtn.addEventListener('click', () => { | |
console.log("DEBUG ONBOARD: 'Pick User to Copy Settings From' button clicked."); | |
const query = prompt("Search for a user to copy settings from (UPN, name, or email):"); | |
if (!query || query.trim() === "") { | |
console.log("DEBUG ONBOARD: No query entered for copy user search."); | |
return; | |
} | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const statusUpdateFn = (msg, type) => { | |
if (currentModalStatusMsg) { | |
currentModalStatusMsg.textContent = msg; | |
currentModalStatusMsg.className = ''; | |
currentModalStatusMsg.style.fontWeight = (type === 'error' || type === 'success') ? 'bold' : ''; | |
if (type === 'error') currentModalStatusMsg.style.color = document.body.classList.contains('theme-dark') ? '#ff8a80' : '#dc3545'; | |
else if (type === 'success') currentModalStatusMsg.style.color = document.body.classList.contains('theme-dark') ? '#a5d6a7' : '#28a745'; | |
else currentModalStatusMsg.style.color = document.body.classList.contains('theme-dark') ? '#aaa' : '#6c757d'; | |
} else { | |
updateStatusMessage(msg, type); | |
} | |
}; | |
statusUpdateFn('Searching for user to copy: ' + query + '...', 'info'); | |
fetch('/search?query=' + encodeURIComponent(query)) | |
.then(response => { | |
console.log("DEBUG ONBOARD: /search response received, status:", response.status); | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('Search failed: HTTP ' + response.status + ' ' + response.statusText); | |
}).then(err => { | |
throw new Error(err.error || ('Search failed: HTTP ' + response.status)); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(results => { | |
console.log("DEBUG ONBOARD: /search results:", results); | |
if (!results || !Array.isArray(results) || results.length === 0) { | |
throw new Error('No users found for query: "' + query + '". Or, unexpected search result format.'); | |
} | |
let msg = "Select a user to copy settings from:\n" + results.map((u, i) => (i + 1) + ". " + (u.displayName || '[No DisplayName]') + " (" + (u.upn || '[No UPN]') + ")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedUserToCopy = results[choice - 1]; | |
console.log("DEBUG ONBOARD: User selected to copy:", selectedUserToCopy); | |
if (onboardCopyUserDisplayInput) { | |
onboardCopyUserDisplayInput.value = selectedUserToCopy.upn; | |
} else { | |
console.warn("DEBUG ONBOARD: onboardCopyUserDisplayInput not found."); | |
} | |
statusUpdateFn('Fetching details for ' + selectedUserToCopy.upn + '...', 'info'); | |
return fetch('/getuserdetails?upn=' + encodeURIComponent(selectedUserToCopy.upn)); | |
} else { | |
statusUpdateFn('Copy user selection cancelled or invalid.', 'info'); | |
return Promise.reject('cancelled'); | |
} | |
}) | |
.then(response => { | |
if (!response) return Promise.reject('cancelled'); | |
console.log("DEBUG ONBOARD: /getuserdetails response received, status:", response.status); | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('Failed to fetch user details: HTTP ' + response.status + ' ' + response.statusText); | |
}).then(err => { | |
throw new Error(err.error || ('Failed to fetch user details: HTTP ' + response.status)); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(copyUserData => { | |
console.log("DEBUG ONBOARD: /getuserdetails data received:", copyUserData); | |
if (copyUserData.Error) { | |
throw new Error('Error in fetched user data: ' + copyUserData.Error); | |
} | |
if (typeof onboardingHintFieldMap === 'undefined') { | |
console.error("DEBUG ONBOARD: onboardingHintFieldMap is not defined!"); | |
throw new Error("Configuration error: onboardingHintFieldMap missing."); | |
} | |
for (const hintId in onboardingHintFieldMap) { | |
const dataKey = onboardingHintFieldMap[hintId]; | |
const hintEl = document.getElementById(hintId); | |
if (hintEl) { | |
hintEl.textContent = copyUserData[dataKey] ? 'Copy: ' + copyUserData[dataKey] : ''; | |
} else { | |
console.warn("DEBUG ONBOARD: Hint element not found for hintId:", hintId); | |
} | |
} | |
if (onboardCopyUserGroupsTextarea) { | |
if (copyUserData.GroupMemberships && copyUserData.GroupMemberships.length > 0) { | |
onboardCopyUserGroupsTextarea.value = copyUserData.GroupMemberships.map(g => g.DisplayName || '[Unnamed Group]').sort().join('\n'); | |
} else { | |
onboardCopyUserGroupsTextarea.value = '[No groups]'; | |
} | |
} else { console.warn("DEBUG ONBOARD: onboardCopyUserGroupsTextarea not found."); } | |
if (onboardCopyUserLicensesTextarea) { | |
if (copyUserData.Licenses && copyUserData.Licenses.length > 0) { | |
onboardCopyUserLicensesTextarea.value = copyUserData.Licenses.sort().join('\n'); | |
} else { | |
onboardCopyUserLicensesTextarea.value = '[No licenses]'; | |
} | |
} else { console.warn("DEBUG ONBOARD: onboardCopyUserLicensesTextarea not found."); } | |
statusUpdateFn('Details from ' + (copyUserData.UserPrincipalName || 'selected user') + ' loaded as hints.', 'success'); | |
}) | |
.catch(error => { | |
console.error("DEBUG ONBOARD: Error in 'Pick User to Copy' process:", error); | |
if (error.message !== 'cancelled' && error !== 'cancelled') { | |
statusUpdateFn('Failed to load copy user details: ' + error.message, 'error'); | |
} | |
}); | |
}); | |
} else { | |
console.warn("DEBUG ONBOARD: onboardPickCopyUserBtn HTML element not found. 'Pick User to Copy Settings From' button will not work."); | |
} | |
// --- Onboarding - Pick Manager Logic --- | |
if (onboardPickManagerBtn && onboardManagerDisplayInput && onboardManagerDisplayHint) { | |
onboardPickManagerBtn.addEventListener('click', () => { | |
const query = prompt("Search for the new user's Manager (name or UPN):"); | |
if (!query || query.trim() === "") return; | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const statusUpdateFn = currentModalStatusMsg ? (msg,type)=>{currentModalStatusMsg.textContent=msg;} : updateStatusMessage; | |
statusUpdateFn('Searching for manager...', 'info'); | |
fetch('/search?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
if (results.error) throw new Error(results.error); | |
if (!results || !Array.isArray(results) || results.length === 0) throw new Error('No users found for that query.'); | |
let msg = "Select the Manager:\n" + results.map((u, i) => (i + 1) + ". " + (u.displayName || '[N/A]') + " (" + (u.upn||'[N/A]') + ")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedManager = results[choice - 1]; | |
onboardManagerDisplayInput.value = selectedManager.upn; | |
onboardManagerDisplayHint.textContent = 'Selected: ' + selectedManager.displayName + ' (' + selectedManager.upn + ')'; | |
statusUpdateFn('Manager selected: ' + selectedManager.displayName, 'success'); | |
} else { statusUpdateFn('Manager selection cancelled or invalid.', 'info');} | |
}).catch(err => { statusUpdateFn('Manager search failed: ' + err.message, 'error'); }); | |
}); | |
} else { console.warn("DEBUG ONBOARD: Pick Manager button or display elements missing."); } | |
// --- Onboarding - Add Groups Logic --- | |
if (onboardAddGroupBtn && onboardSelectedGroupsTextarea) { | |
onboardAddGroupBtn.addEventListener('click', () => { | |
const query = prompt("Search for a group to add (name):"); | |
if (!query || query.trim() === "") return; | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const statusUpdateFn = currentModalStatusMsg ? (msg,type)=>{currentModalStatusMsg.textContent=msg;} : updateStatusMessage; | |
statusUpdateFn('Searching for group...', 'info'); | |
fetch('/searchgroups?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
if (results.error) throw new Error(results.error); | |
if (!results || !Array.isArray(results) || results.length === 0) throw new Error('No groups found for that query.'); | |
let msg = "Select a group to add:\n"; | |
results.forEach((g, i) => { msg += (i + 1) + ". " + (g.displayName||'[N/A]') + "\n"; }); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedGroup = results[choice - 1]; | |
const currentGroups = onboardSelectedGroupsTextarea.value.split('\n').filter(g => g.trim() !== ''); | |
if (!currentGroups.includes(selectedGroup.displayName)) { | |
onboardSelectedGroupsTextarea.value += (currentGroups.length > 0 ? '\n' : '') + selectedGroup.displayName; | |
statusUpdateFn('Group "' + selectedGroup.displayName + '" added.', 'success'); | |
} else { statusUpdateFn('Group "' + selectedGroup.displayName + '" already in list.', 'info'); } | |
} else { statusUpdateFn('Group selection cancelled or invalid.', 'info'); } | |
}).catch(err => { statusUpdateFn('Group search failed: ' + err.message, 'error'); }); | |
}); | |
} else { console.warn("DEBUG ONBOARD: Add Group button or textarea missing."); } | |
// --- Onboarding - Add Delegation Logic --- | |
if (onboardAddDelegationBtn && onboardSelectedDelegationsTextarea) { | |
onboardAddDelegationBtn.addEventListener('click', () => { | |
const query = prompt("Search for a user to grant the new user delegate access TO (e.g., a manager's mailbox):"); | |
if (!query || query.trim() === "") return; | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const statusUpdateFn = currentModalStatusMsg ? (msg,type)=>{currentModalStatusMsg.textContent=msg;} : updateStatusMessage; | |
statusUpdateFn('Searching for user...', 'info'); | |
fetch('/search?query=' + encodeURIComponent(query)) | |
.then(res => res.json()) | |
.then(results => { | |
if (results.error) throw new Error(results.error); | |
if (!results || !Array.isArray(results) || results.length === 0) throw new Error('No users found for that query.'); | |
let msg = "Select a user/mailbox for delegation:\n" + results.map((u, i) => (i + 1) + ". " + (u.displayName||'[N/A]') + " (" + (u.upn||'[N/A]') + ")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { | |
const selectedUser = results[choice - 1]; | |
const currentDelegations = onboardSelectedDelegationsTextarea.value.split('\n').filter(d => d.trim() !== ''); | |
if (!currentDelegations.includes(selectedUser.upn)) { | |
onboardSelectedDelegationsTextarea.value += (currentDelegations.length > 0 ? '\n' : '') + selectedUser.upn; | |
statusUpdateFn('User "' + selectedUser.upn + '" added for delegation.', 'success'); | |
} else { statusUpdateFn('User "' + selectedUser.upn + '" already in delegation list.', 'info'); } | |
} else { statusUpdateFn('Delegation selection cancelled or invalid.', 'info'); } | |
}).catch(err => { statusUpdateFn('User search for delegation failed: ' + err.message, 'error'); }); | |
}); | |
} else { console.warn("DEBUG ONBOARD: Add Delegation button or textarea missing."); } | |
// --- Onboarding - License Picker Sub-Modal Logic --- | |
if (onboardPickLicensesBtn) { | |
onboardPickLicensesBtn.addEventListener('click', () => { | |
console.log("DEBUG ONBOARD: onboardPickLicensesBtn clicked."); | |
const statusContainer = document.getElementById('pickLicenseStatusContainer'); | |
const statusMessage = document.getElementById('pickLicenseStatusMessage'); | |
if (!pickLicenseSubModal || !statusContainer || !statusMessage || !pickableLicensesList) { | |
console.error("DEBUG ONBOARD CRITICAL: Essential elements for pickLicenseSubModal are missing. Check HTML IDs."); | |
alert("Error: License picker modal UI components are missing."); | |
return; | |
} | |
statusContainer.style.display = 'block'; | |
statusMessage.textContent = 'Loading available tenant licenses...'; | |
pickableLicensesList.innerHTML = '<option>Loading...</option>'; | |
if(filterPickableLicensesInput) filterPickableLicensesInput.value = ''; | |
fetch('/gettenantlicenses') | |
.then(response => { | |
if (!response.ok) { | |
return response.json().catch(() => { | |
throw new Error('Failed to load tenant licenses: HTTP ' + response.status + ' ' + response.statusText); | |
}).then(err => { | |
throw new Error(err.error || 'Failed to load tenant licenses: HTTP ' + response.status + ' ' + response.statusText); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
allPickableTenantLicenses = data.licenses || []; | |
pickableLicensesList.innerHTML = ''; | |
if (allPickableTenantLicenses.length === 0) { | |
pickableLicensesList.add(new Option('[No licenses available in tenant]', '')); | |
} else { | |
allPickableTenantLicenses.forEach(lic => { | |
const displayText = lic.friendlyName + ' (' + lic.available + '/' + lic.total + ' avail)'; | |
const option = new Option(displayText, lic.skuId); | |
option.dataset.friendlyName = lic.friendlyName; | |
option.dataset.skuPartNumber = lic.skuPartNumber || ''; | |
if (selectedLicensesForOnboarding.some(s => s.skuId === lic.skuId)) { | |
option.selected = true; | |
} | |
pickableLicensesList.add(option); | |
}); | |
} | |
statusContainer.style.display = 'none'; | |
pickLicenseSubModal.style.display = 'block'; | |
}) | |
.catch(error => { | |
console.error("DEBUG ONBOARD: Error in fetch /gettenantlicenses or processing:", error); | |
if (statusMessage) statusMessage.textContent = 'Failed to load licenses: ' + error.message; | |
if (pickableLicensesList) pickableLicensesList.innerHTML = '<option>Error loading licenses</option>'; | |
pickLicenseSubModal.style.display = 'block'; | |
}); | |
}); | |
} else { | |
console.warn("DEBUG ONBOARD: onboardPickLicensesBtn HTML element not found. 'Pick Licenses' button will not work."); | |
} | |
if (filterPickableLicensesInput) { | |
filterPickableLicensesInput.addEventListener('input', (e) => { | |
const filterText = e.target.value.toLowerCase(); | |
pickableLicensesList.innerHTML = ''; | |
const licensesToDisplay = allPickableTenantLicenses.filter(lic => | |
lic.friendlyName.toLowerCase().includes(filterText) || | |
(lic.skuPartNumber && lic.skuPartNumber.toLowerCase().includes(filterText)) | |
); | |
if (licensesToDisplay.length === 0) { | |
pickableLicensesList.add(new Option('[No licenses match filter]', '')); | |
} else { | |
licensesToDisplay.forEach(lic => { | |
const displayText = lic.friendlyName + ' (' + lic.available + '/' + lic.total + ' avail)'; | |
const option = new Option(displayText, lic.skuId); | |
option.dataset.friendlyName = lic.friendlyName; | |
option.dataset.skuPartNumber = lic.skuPartNumber || ''; | |
if (selectedLicensesForOnboarding.some(s => s.skuId === lic.skuId)) { | |
option.selected = true; | |
} | |
pickableLicensesList.add(option); | |
}); | |
} | |
}); | |
} | |
if (confirmLicenseSelectionBtn) { | |
confirmLicenseSelectionBtn.addEventListener('click', () => { | |
selectedLicensesForOnboarding = []; | |
const friendlyNamesForDisplay = []; | |
Array.from(pickableLicensesList.selectedOptions).forEach(opt => { | |
if (opt.value && opt.value !== '') { | |
selectedLicensesForOnboarding.push({ | |
skuId: opt.value, | |
friendlyName: opt.dataset.friendlyName | |
}); | |
friendlyNamesForDisplay.push(opt.dataset.friendlyName); | |
} | |
}); | |
if(onboardSelectedLicensesTextarea) onboardSelectedLicensesTextarea.value = friendlyNamesForDisplay.sort().join('\n'); | |
else console.warn("DEBUG ONBOARD: onboardSelectedLicensesTextarea not found for displaying licenses."); | |
pickLicenseSubModal.style.display = 'none'; | |
}); | |
} | |
if (cancelLicenseSelectionBtn) { | |
cancelLicenseSelectionBtn.addEventListener('click', () => { | |
pickLicenseSubModal.style.display = 'none'; | |
}); | |
} | |
// --- Onboarding - Password Section Logic --- | |
if (onboardShowPasswordChk && onboardPasswordInput) { | |
onboardShowPasswordChk.addEventListener('click', () => { | |
onboardPasswordInput.type = onboardShowPasswordChk.checked ? 'text' : 'password'; | |
}); | |
} else { console.warn("DEBUG ONBOARD: Show Password checkbox or input not found."); } | |
if (onboardCopyPasswordBtn && onboardPasswordInput) { | |
onboardCopyPasswordBtn.addEventListener('click', () => { | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const statusUpdateFn = currentModalStatusMsg ? (msg,type)=>{currentModalStatusMsg.textContent=msg;} : updateStatusMessage; | |
if (onboardPasswordInput.value) { | |
navigator.clipboard.writeText(onboardPasswordInput.value) | |
.then(() => { | |
statusUpdateFn('Password copied to clipboard!', 'success'); | |
playNotificationSound(); | |
}) | |
.catch(err => { | |
statusUpdateFn('Failed to copy password: ' + err.message, 'error'); | |
}); | |
} else { | |
statusUpdateFn('No password to copy.', 'info'); | |
} | |
}); | |
} else { console.warn("DEBUG ONBOARD: Copy Password button or input not found."); } | |
// --- Onboarding - Create User Button Logic --- | |
if (onboardCreateUserBtn && onboardForm) { | |
onboardCreateUserBtn.addEventListener('click', async (event) => { | |
event.preventDefault(); | |
const currentModalStatusMsg = document.getElementById('manageModalStatusMessage'); | |
const onboardStatusContainer = document.getElementById('manageModalStatusContainer'); | |
onboardCreateUserBtn.disabled = true; | |
if (onboardStatusContainer) onboardStatusContainer.style.display = 'block'; | |
function updateOnboardingStatus(message, type = 'info') { | |
if (currentModalStatusMsg && onboardStatusContainer) { | |
currentModalStatusMsg.textContent = message; | |
currentModalStatusMsg.className = ''; | |
onboardStatusContainer.style.backgroundColor = ''; | |
currentModalStatusMsg.style.fontWeight = (type === 'error' || type === 'success') ? 'bold' : ''; | |
if (type === 'error') currentModalStatusMsg.style.color = document.body.classList.contains('theme-dark') ? '#ff8a80' : '#dc3545'; | |
else if (type === 'success') currentModalStatusMsg.style.color = document.body.classList.contains('theme-dark') ? '#a5d6a7' : '#28a745'; | |
else currentModalStatusMsg.style.color = document.body.classList.contains('theme-dark') ? '#aaa' : '#6c757d'; | |
} | |
} | |
try { | |
// --- Collect Form Data and Validate --- | |
updateOnboardingStatus('Collecting form data...', 'info'); | |
const newUsername = document.getElementById('onboardUsername').value.trim(); | |
const selectedDomain = document.getElementById('onboardDomainDropdown').value; | |
const displayName = document.getElementById('onboardDisplayName').value.trim(); | |
const password = document.getElementById('onboardPassword').value; // Password is not trimmed | |
const usageLocation = document.getElementById('onboardUsageLocation').value.trim(); | |
// Core mandatory field checks based on your request + API/functional needs | |
if (!newUsername) { | |
throw new Error("Username is required."); | |
} | |
if (!selectedDomain) { | |
throw new Error("Domain is required."); | |
} | |
if (!displayName) { | |
throw new Error("Display Name is required."); | |
} | |
if (!password) { | |
throw new Error("Password is required. You can use the generator buttons if needed."); | |
} | |
if (!usageLocation) { | |
// UsageLocation is essential for assigning any licenses (Step 2 of your onboarding). | |
throw new Error("Usage Location is required (e.g., US, GB). This is needed for license assignment."); | |
} | |
const newUserUpn = newUsername + '@' + selectedDomain; | |
const formData = { | |
userPrincipalName: newUserUpn, | |
displayName: displayName, | |
givenName: document.getElementById('onboardFirstName').value.trim(), | |
surname: document.getElementById('onboardLastName').value.trim(), | |
jobTitle: document.getElementById('onboardJobTitle').value.trim(), | |
department: document.getElementById('onboardDepartment').value.trim(), | |
companyName: document.getElementById('onboardCompany').value.trim(), | |
managerUpn: document.getElementById('onboardManagerDisplay').value.trim(), | |
officeLocation: document.getElementById('onboardOfficeLocation').value.trim(), | |
streetAddress: document.getElementById('onboardStreetAddress').value.trim(), | |
city: document.getElementById('onboardCity').value.trim(), | |
state: document.getElementById('onboardState').value.trim(), | |
postalCode: document.getElementById('onboardPostalCode').value.trim(), | |
country: document.getElementById('onboardCountry').value.trim(), | |
mobilePhone: document.getElementById('onboardMobilePhone').value.trim(), | |
businessPhones: [document.getElementById('onboardBusinessPhone').value.trim()].filter(p => p), | |
usageLocation: usageLocation, // Already validated | |
password: password, // Already validated | |
notes: document.getElementById('onboardNotes').value.trim(), | |
forceChangePassword: document.getElementById('onboardForceChangePasswordChk').checked, | |
licenseSkuIds: selectedLicensesForOnboarding.map(lic => lic.skuId), | |
groupsToAdd: document.getElementById('onboardSelectedGroups').value.split('\n').filter(g => g.trim() !== ''), | |
delegationsToAssign: document.getElementById('onboardSelectedDelegations').value.split('\n').filter(u => u.trim() !== '') | |
.map(delegateUpn => ({ delegateUpn: delegateUpn, permissionType: 'FullAccess', automap: true })) | |
}; | |
if (!formData.givenName || !formData.surname || !formData.displayName || !formData.usageLocation || !formData.password) { | |
throw new Error("First Name, Last Name, Display Name, Usage Location, and Password are required."); | |
} | |
updateOnboardingStatus('Form data collected for ' + newUserUpn + '.', 'info'); | |
await new Promise(resolve => setTimeout(resolve, 500)); | |
updateOnboardingStatus('Step 1/4: Creating core user account...', 'info'); | |
let response = await fetch('/onboard/createcoreuser', { | |
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(formData) | |
}); | |
let result = await response.json(); | |
if (!response.ok || result.success === false) throw new Error(result.message || 'Failed to create core user.'); | |
updateOnboardingStatus('Step 1/4: Core user created: ' + result.message + '. Verifying...', 'success'); | |
await new Promise(resolve => setTimeout(resolve, 3000)); | |
let verificationAttempts = 0; | |
let verified = false; | |
while (verificationAttempts < 5 && !verified) { | |
verificationAttempts++; | |
updateOnboardingStatus('Step 1/4: Verification attempt ' + verificationAttempts + ' for ' + newUserUpn + '...', 'info'); | |
try { | |
const checkResp = await fetch('/getaccountstatus?upn=' + encodeURIComponent(newUserUpn)); | |
const checkData = await checkResp.json(); | |
if (checkResp.ok && checkData.hasOwnProperty('enabled')) { | |
verified = true; | |
updateOnboardingStatus('Step 1/4: User account verified.', 'success'); | |
break; | |
} | |
} catch (e) { console.warn("Verification attempt failed, retrying...", e); } | |
if (!verified) await new Promise(resolve => setTimeout(resolve, 5000)); | |
} | |
if (!verified) throw new Error('User account verification failed after multiple attempts.'); | |
if (formData.notes && formData.notes.trim() !== '') { | |
updateOnboardingStatus('Step 1.5/5: Adding user notes...', 'info'); | |
try { | |
const notesPayload = { | |
upn: newUserUpn, | |
changes: { Notes: formData.notes } | |
}; | |
const notesResponse = await fetch('/updateuserdetails', { | |
method: 'POST', | |
headers: {'Content-Type': 'application/json'}, | |
body: JSON.stringify(notesPayload) | |
}); | |
const notesResult = await notesResponse.json(); | |
if (!notesResponse.ok || notesResult.success === false) { | |
throw new Error(notesResult.details || notesResult.error || 'Failed to set notes.'); | |
} | |
updateOnboardingStatus('Step 1.5/5: User notes added.', 'success'); | |
} catch (notesError) { | |
// Log as a warning but continue the onboarding process | |
updateOnboardingStatus('Step 1.5/5: WARNING - Could not add notes: ' + notesError.message, 'error'); | |
await new Promise(resolve => setTimeout(resolve, 2000)); // Pause/wait to see warning | |
} | |
} | |
if (formData.licenseSkuIds && formData.licenseSkuIds.length > 0) { | |
updateOnboardingStatus('Step 2/4: Assigning licenses...', 'info'); | |
response = await fetch('/onboard/assignlicenses', { | |
method: 'POST', headers: {'Content-Type': 'application/json'}, | |
body: JSON.stringify({ upn: newUserUpn, licenseSkuIds: formData.licenseSkuIds }) | |
}); | |
result = await response.json(); | |
if (!response.ok || result.success === false) throw new Error(result.message || 'Failed to assign licenses.'); | |
updateOnboardingStatus('Step 2/4: Licenses assigned. Checking for mailbox provisioning (this may take a few minutes)...', 'success'); | |
let mailboxAttempts = 0; | |
let mailboxExists = false; | |
while (mailboxAttempts < 12 && !mailboxExists) { | |
mailboxAttempts++; | |
updateOnboardingStatus('Step 2/4: Mailbox check attempt ' + mailboxAttempts + '...', 'info'); | |
try { | |
const mbxResp = await fetch('/getmailboxstatus?upn=' + encodeURIComponent(newUserUpn)); | |
const mbxData = await mbxResp.json(); | |
if (mbxResp.ok && mbxData.exists === true) { | |
mailboxExists = true; | |
updateOnboardingStatus('Step 2/4: Mailbox detected.', 'success'); | |
break; | |
} | |
} catch (e) { console.warn("Mailbox check attempt failed, retrying...", e); } | |
if (!mailboxExists) await new Promise(resolve => setTimeout(resolve, 10000)); | |
} | |
if (!mailboxExists) { | |
updateOnboardingStatus('Step 2/4: Mailbox not detected after timeout. Subsequent mailbox-related tasks may fail.', 'warning'); | |
} | |
} else { | |
updateOnboardingStatus('Step 2/4: No licenses selected to assign.', 'info'); | |
} | |
await new Promise(resolve => setTimeout(resolve, 500)); | |
if (formData.groupsToAdd && formData.groupsToAdd.length > 0) { | |
updateOnboardingStatus('Step 3/4: Adding user to groups...', 'info'); | |
for (const groupName of formData.groupsToAdd) { | |
updateOnboardingStatus('Step 3/4: Adding to group: ' + groupName + '...', 'info'); | |
try { | |
response = await fetch('/onboard/addtogroup', { | |
method: 'POST', headers: {'Content-Type': 'application/json'}, | |
body: JSON.stringify({ upn: newUserUpn, groupName: groupName }) | |
}); | |
result = await response.json(); | |
if (!response.ok || result.success === false) { | |
updateOnboardingStatus('Step 3/4: Failed to add to group ' + groupName + ': ' + (result.message || 'Unknown error'), 'error'); | |
} else { | |
updateOnboardingStatus('Step 3/4: Added to group ' + groupName + '.', 'success'); | |
} | |
} catch (e) { updateOnboardingStatus('Step 3/4: Error adding to group ' + groupName + ': ' + e.message, 'error');} | |
await new Promise(resolve => setTimeout(resolve, 200)); | |
} | |
} else { | |
updateOnboardingStatus('Step 3/4: No groups selected for assignment.', 'info'); | |
} | |
if (formData.delegationsToAssign && formData.delegationsToAssign.length > 0) { | |
updateOnboardingStatus('Step 3/4: Setting up mailbox delegations...', 'info'); | |
for (const delegation of formData.delegationsToAssign) { | |
updateOnboardingStatus('Step 3/4: Delegating ' + delegation.permissionType + ' to ' + delegation.delegateUpn + '...', 'info'); | |
try { | |
response = await fetch('/onboard/setdelegate', { | |
method: 'POST', headers: {'Content-Type': 'application/json'}, | |
body: JSON.stringify({ targetMailboxUpn: newUserUpn, delegateToUpn: delegation.delegateUpn, permissionType: delegation.permissionType, automap: delegation.automap }) | |
}); | |
result = await response.json(); | |
if (!response.ok || result.success === false) { | |
updateOnboardingStatus('Step 3/4: Failed delegation for ' + delegation.delegateUpn + ': ' + (result.message || 'Unknown error'), 'error'); | |
} else { | |
updateOnboardingStatus('Step 3/4: Delegation set for ' + delegation.delegateUpn + '.', 'success'); | |
} | |
} catch (e) { updateOnboardingStatus('Step 3/4: Error setting delegation for ' + delegation.delegateUpn + ': ' + e.message, 'error');} | |
await new Promise(resolve => setTimeout(resolve, 200)); | |
} | |
} else { | |
updateOnboardingStatus('Step 3/4: No delegations selected.', 'info'); | |
} | |
await new Promise(resolve => setTimeout(resolve, 500)); | |
updateOnboardingStatus('Step 4/4: Backing up new user details...', 'info'); | |
response = await fetch('/backupuser?upn=' + encodeURIComponent(newUserUpn)); | |
const backupText = await response.text(); | |
if (!response.ok) throw new Error('Backup failed: ' + backupText); | |
updateOnboardingStatus('Step 4/4: Backup successful: ' + backupText, 'success'); | |
await new Promise(resolve => setTimeout(resolve, 500)); | |
updateOnboardingStatus('Onboarding process for ' + newUserUpn + ' completed successfully! You may want to clear the form or load the new user.', 'success'); | |
playNotificationSound(); | |
} catch (error) { | |
console.error('Onboarding Process Error:', error); | |
updateOnboardingStatus('ONBOARDING FAILED: ' + error.message, 'error'); | |
} finally { | |
if(onboardCreateUserBtn) onboardCreateUserBtn.disabled = false; | |
} | |
}); | |
} else { console.warn("DEBUG ONBOARD: Create User button or Onboarding form not found.");} | |
// --- Run PowerShell Command Modal Logic ---------------------------------------------------------------------------------------------------------------------------------------------------------- | |
const runPsCommandBtn = document.getElementById('runPsCommandBtn'); | |
const runPsCommandModal = document.getElementById('runPsCommandModal'); | |
const psCommandInput = document.getElementById('psCommandInput'); | |
const executePsCommandBtn = document.getElementById('executePsCommandBtn'); | |
const clearPsInputBtn = document.getElementById('clearPsInputBtn'); | |
const closeRunPsModalBtn = document.getElementById('closeRunPsModalBtn'); | |
const runPsCommandStatusContainer = document.getElementById('runPsCommandStatusContainer'); | |
const runPsCommandStatusMessage = document.getElementById('runPsCommandStatusMessage'); | |
// Listener to OPEN the modal | |
if (runPsCommandBtn) { | |
runPsCommandBtn.addEventListener('click', () => { | |
console.log("DEBUG PSMODAL: 'Run PS Command' button (runPsCommandBtn) clicked to open modal."); | |
if (!runPsCommandModal) { | |
console.error("DEBUG PSMODAL CRITICAL: runPsCommandModal HTML element NOT FOUND. Modal cannot open. Check ID 'runPsCommandModal'."); | |
alert('Error: Run PS Command modal structure is missing.'); | |
return; | |
} | |
console.log("DEBUG PSMODAL: runPsCommandModal element found."); | |
if (psCommandInput) { | |
psCommandInput.value = lastPsCommand; // Option 1: Restore last command | |
//psCommandInput.value = ''; // Option 2: Clear input when modal opens by default | |
} else { | |
console.warn("DEBUG PSMODAL: psCommandInput element not found for clearing."); | |
} | |
if (runPsCommandStatusMessage) { | |
runPsCommandStatusMessage.textContent = ''; | |
} else { | |
console.warn("DEBUG PSMODAL: runPsCommandStatusMessage span not found."); | |
} | |
if (runPsCommandStatusContainer) { | |
runPsCommandStatusContainer.style.display = 'none'; | |
} else { | |
console.warn("DEBUG PSMODAL: runPsCommandStatusContainer div not found."); | |
} | |
runPsCommandModal.style.display = 'block'; | |
console.log("DEBUG PSMODAL: runPsCommandModal display style set to 'block'."); | |
}); | |
} else { | |
console.warn("DEBUG PSMODAL: runPsCommandBtn (the button that opens the modal) was NOT found in your HTML. Check its ID in the main button bar: should be 'runPsCommandBtn'."); | |
} | |
// --- PsCommand - Listener for EXECUTE button --- | |
if (executePsCommandBtn) { | |
executePsCommandBtn.addEventListener('click', () => { | |
console.log("DEBUG PSMODAL: 'Execute Command' button clicked."); | |
if (!psCommandInput || !runPsCommandStatusMessage || !runPsCommandStatusContainer) { | |
console.error("DEBUG PSMODAL: Missing one or more elements required for executePsCommandBtn (psCommandInput, runPsCommandStatusMessage, or runPsCommandStatusContainer)."); | |
alert("Error: UI elements for command execution are missing."); | |
return; | |
} | |
const command = psCommandInput.value; | |
if (!command || command.trim() === "") { | |
runPsCommandStatusMessage.textContent = "No command entered."; | |
runPsCommandStatusContainer.style.display = 'block'; | |
return; | |
} | |
lastPsCommand = command; // Assuming lastPsCommand is a global var for command history | |
runPsCommandStatusMessage.textContent = 'Sending command: ' + command.substring(0, 50) + '...'; | |
runPsCommandStatusContainer.style.display = 'block'; | |
// No local output area to update with "Executing..." | |
fetch('/runpscommand', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
body: 'command=' + encodeURIComponent(command) | |
}) | |
.then(response => response.json()) // Expecting JSON from the backend | |
.then(data => { | |
console.log("DEBUG PSMODAL: Received data from /runpscommand:", data); | |
if (runPsCommandStatusMessage) { | |
runPsCommandStatusMessage.textContent = data.confirmation || 'Process completed.'; | |
} | |
// The actual output data.output is only intended for the PS console | |
// But also logging the output into the console log | |
console.log('DEBUG PSMODAL: Backend command output (for PS Console):', data.output); | |
if (data.success === false) { | |
// If backend indicates an issue, ensure status reflects it | |
if (runPsCommandStatusMessage && data.confirmation) { | |
// Confirmation might already be an error message from backend | |
} else if (runPsCommandStatusMessage) { | |
runPsCommandStatusMessage.textContent = 'Command processed with issues (check PS console).'; | |
} | |
} | |
}) | |
.catch(error => { | |
const errorMsg = "Error sending command or processing response: " + error.message; | |
if (runPsCommandStatusMessage) { | |
runPsCommandStatusMessage.textContent = errorMsg; | |
} | |
console.error("DEBUG PSMODAL: Error during command execution fetch:", error); | |
}); | |
}); | |
} else { | |
console.warn("DEBUG PSMODAL: executePsCommandBtn HTML element not found. Check ID 'executePsCommandBtn'."); | |
} | |
// Listener for CLEAR INPUT button | |
if (clearPsInputBtn) { | |
clearPsInputBtn.addEventListener('click', () => { | |
console.log("DEBUG PSMODAL: 'Clear Input' button clicked."); | |
if (psCommandInput) { | |
psCommandInput.value = ''; | |
} else { | |
console.warn("DEBUG PSMODAL: psCommandInput not found for clearing."); | |
} | |
if (runPsCommandStatusMessage) { | |
runPsCommandStatusMessage.textContent = 'Command input cleared.'; | |
} | |
if (runPsCommandStatusContainer) { | |
runPsCommandStatusContainer.style.display = 'block'; | |
} | |
}); | |
} else { | |
console.warn("DEBUG PSMODAL: clearPsInputBtn HTML element not found. Check ID 'clearPsInputBtn'."); | |
} | |
// --- PSCommand - Listener for CLOSE button --- | |
if (closeRunPsModalBtn) { | |
closeRunPsModalBtn.addEventListener('click', () => { | |
console.log("DEBUG PSMODAL: 'Close' button clicked for Run PS modal."); | |
if (runPsCommandModal) { | |
runPsCommandModal.style.display = 'none'; | |
} else { | |
console.warn("DEBUG PSMODAL: runPsCommandModal not found for closing."); | |
} | |
}); | |
} else { | |
console.warn("DEBUG PSMODAL: closeRunPsModalBtn HTML element not found. Check ID 'closeRunPsModalBtn'."); | |
} | |
// --- Onboarding - Helper function to populate the onboarding domain dropdown --- | |
function populateOnboardingDomains() { | |
const domainDropdown = document.getElementById('onboardDomainDropdown'); | |
if (!domainDropdown) return; | |
domainDropdown.innerHTML = '<option value="">Loading...</option>'; // Placeholder | |
fetch('/gettenantdomains') | |
.then(response => { | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'HTTP Error ' + response.status); }); | |
return response.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
domainDropdown.innerHTML = ''; // Clear loading message | |
if (data.domains && data.domains.length > 0) { | |
data.domains.forEach(domain => { | |
domainDropdown.add(new Option(domain, domain)); | |
}); | |
// Set the dropdown's value to the tenant's default domain | |
if (data.defaultDomain) { | |
domainDropdown.value = data.defaultDomain; | |
} | |
} else { | |
domainDropdown.add(new Option('No domains found', '')); | |
} | |
}) | |
.catch(error => { | |
console.error("Failed to load domains for onboarding dropdown:", error); | |
if (domainDropdown) domainDropdown.innerHTML = '<option value="">Error loading</option>'; | |
}); | |
} | |
// --- Manage User - A dedicated function just for the main Manage User modal's status strip | |
function showManageModalStatus(message, type = 'info') { | |
const container = document.getElementById('manageModalStatusContainer'); | |
const span = document.getElementById('manageModalStatusMessage'); | |
if (!container || !span) { | |
console.error("Manage Modal status strip elements not found!"); | |
return; | |
} | |
// Set the text content | |
span.textContent = message; | |
// Set the class for text color (CSS handles the actual color) | |
span.classList.remove('status-error', 'status-success', 'status-info'); | |
if (type === 'error') { | |
span.classList.add('status-error'); | |
span.style.fontWeight = 'bold'; | |
} else if (type === 'success') { | |
span.classList.add('status-success'); | |
span.style.fontWeight = 'bold'; | |
} else { | |
span.classList.add('status-info'); | |
span.style.fontWeight = ''; | |
} | |
// Make the container visible | |
container.style.display = 'block'; | |
} | |
// --- COMPLETE Manage Delegation & Forwarding Modal Logic (Corrected Structure) -------------------------------------------------------------------------------------------------------------------- | |
const manageDelegationForwardingModal = document.getElementById('manageDelegationForwardingModal'); | |
const manageDelegateAccessBtn = document.getElementById('manageDelegateAccessBtn'); | |
const manageSetupForwardingBtn = document.getElementById('manageSetupForwardingBtn'); | |
const closeDelFwdModalBtn = document.getElementById('closeDelFwdModalBtn'); | |
const saveDelFwdChangesBtn = document.getElementById('saveDelFwdChangesBtn'); | |
const delFwdUserUpnSpan = document.getElementById('delFwdUserUpn'); | |
const delFwdStatusContainer = document.getElementById('delFwdStatusContainer'); | |
const delFwdStatusMessage = document.getElementById('delFwdStatusMessage'); | |
const delegationTabBtn = document.getElementById('delegationTabBtn'); | |
const forwardingTabBtn = document.getElementById('forwardingTabBtn'); | |
const delegationsContentDiv = document.getElementById('delegationsContentDiv'); | |
const forwardingContentDiv = document.getElementById('forwardingContentDiv'); | |
const currentFullAccessList = document.getElementById('currentFullAccessList'); | |
const currentSendAsList = document.getElementById('currentSendAsList'); | |
const currentSendOnBehalfList = document.getElementById('currentSendOnBehalfList'); | |
const stageDelegationForRemovalBtn = document.getElementById('stageDelegationForRemovalBtn'); | |
const delegateUserInput = document.getElementById('delegateUserInput'); | |
const pickDelegateUserBtn = document.getElementById('pickDelegateUserBtn'); | |
const chkFullAccess = document.getElementById('chkFullAccess'); | |
const automapOptionDiv = document.getElementById('automapOptionDiv'); | |
const chkAutoMapping = document.getElementById('chkAutoMapping'); | |
const chkSendAs = document.getElementById('chkSendAs'); | |
const chkSendOnBehalf = document.getElementById('chkSendOnBehalf'); | |
const stageDelegationChangeBtn = document.getElementById('stageDelegationChangeBtn'); | |
const stagedDelegationChangesList = document.getElementById('stagedDelegationChangesList'); | |
const unstageSelectedDelegationBtn = document.getElementById('unstageSelectedDelegationBtn'); | |
const currentForwardingAddressInput = document.getElementById('currentForwardingAddress'); | |
const currentDeliverToMailboxAndForwardChk = document.getElementById('currentDeliverToMailboxAndForward'); | |
const fwdStateDisableRadio = document.getElementById('fwdStateDisable'); | |
const fwdStateEnableRadio = document.getElementById('fwdStateEnable'); | |
const fwdSettingsDiv = document.getElementById('fwdSettingsDiv'); | |
const forwardToUserInput = document.getElementById('forwardToUserInput'); | |
const pickForwardToUserBtn = document.getElementById('pickForwardToUserBtn'); | |
const chkDeliverToMailboxAndForward = document.getElementById('chkDeliverToMailboxAndForward'); | |
// --- Variables for this Modal --- | |
let delFwd_originalDelegations = {}; | |
let delFwd_originalForwardingSettings = {}; | |
let delFwd_stagedDelegationActions = []; | |
// --- Helper Functions for this Modal --- | |
function delFwd_updateForwardingControlsState() { | |
const fwdEnableRadio = document.getElementById('fwdStateEnable'); | |
const forwardToUserInput = document.getElementById('forwardToUserInput'); | |
const pickForwardToUserBtn = document.getElementById('pickForwardToUserBtn'); | |
const chkDeliverToMailboxAndForward = document.getElementById('chkDeliverToMailboxAndForward'); | |
const isEnabled = fwdEnableRadio ? fwdEnableRadio.checked : false; | |
if (forwardToUserInput) forwardToUserInput.disabled = !isEnabled; | |
if (pickForwardToUserBtn) pickForwardToUserBtn.disabled = !isEnabled; | |
if (chkDeliverToMailboxAndForward) chkDeliverToMailboxAndForward.disabled = !isEnabled; | |
if (!isEnabled) { if (forwardToUserInput) forwardToUserInput.value = ''; } | |
} | |
function delFwd_populateUI(data) { | |
const delegations = data.delegations || {}; | |
const settings = data.forwarding || {}; | |
delFwd_originalDelegations = { | |
fullAccess: Array.isArray(delegations.fullAccess) ? [...delegations.fullAccess] : [], | |
sendAs: Array.isArray(delegations.sendAs) ? [...delegations.sendAs] : [], | |
sendOnBehalf: Array.isArray(delegations.sendOnBehalf) ? [...delegations.sendOnBehalf] : [] | |
}; | |
delFwd_originalForwardingSettings = { | |
forwardingSmtpAddress: settings.forwardingSmtpAddress || null, | |
deliverToMailboxAndForward: settings.deliverToMailboxAndForward || false | |
}; | |
const lists = { currentFullAccessList: delFwd_originalDelegations.fullAccess, currentSendAsList: delFwd_originalDelegations.sendAs, currentSendOnBehalfList: delFwd_originalDelegations.sendOnBehalf }; | |
for (const id in lists) { | |
const el = document.getElementById(id); | |
if (el) { | |
el.innerHTML = ''; | |
lists[id].forEach(user => { if (typeof user === 'string' && user.trim() !== '') el.add(new Option(user, user)); }); | |
} | |
} | |
const currentFwdAddressInput = document.getElementById('currentForwardingAddress'); | |
const currentDeliverFwdChk = document.getElementById('currentDeliverToMailboxAndForward'); | |
const fwdEnableRadio = document.getElementById('fwdStateEnable'); | |
const fwdDisableRadio = document.getElementById('fwdStateDisable'); | |
const forwardToUserInput = document.getElementById('forwardToUserInput'); | |
const chkDeliverFwd = document.getElementById('chkDeliverToMailboxAndForward'); | |
if (delFwd_originalForwardingSettings.forwardingSmtpAddress) { | |
if (currentFwdAddressInput) currentFwdAddressInput.value = delFwd_originalForwardingSettings.forwardingSmtpAddress; | |
if (currentDeliverFwdChk) currentDeliverFwdChk.checked = delFwd_originalForwardingSettings.deliverToMailboxAndForward; | |
if (fwdEnableRadio) fwdEnableRadio.checked = true; | |
if (forwardToUserInput) forwardToUserInput.value = delFwd_originalForwardingSettings.forwardingSmtpAddress; | |
if (chkDeliverFwd) chkDeliverFwd.checked = delFwd_originalForwardingSettings.deliverToMailboxAndForward; | |
} else { | |
if (currentFwdAddressInput) currentFwdAddressInput.value = 'Not set'; | |
if (currentDeliverFwdChk) currentDeliverFwdChk.checked = false; | |
if (fwdDisableRadio) fwdDisableRadio.checked = true; | |
} | |
delFwd_updateForwardingControlsState(); | |
} | |
function delFwd_resetModal() { | |
const delegateUserInput = document.getElementById('delegateUserInput'); const automapOptionDiv = document.getElementById('automapOptionDiv'); | |
const chkFullAccess = document.getElementById('chkFullAccess'); const chkSendAs = document.getElementById('chkSendAs'); | |
const chkSendOnBehalf = document.getElementById('chkSendOnBehalf'); const chkAutoMapping = document.getElementById('chkAutoMapping'); | |
if(document.getElementById('delFwdStatusContainer')) document.getElementById('delFwdStatusContainer').style.display = 'none'; | |
['currentFullAccessList', 'currentSendAsList', 'currentSendOnBehalfList', 'stagedDelegationChangesList'].forEach(id => { const el = document.getElementById(id); if(el) el.innerHTML = ''; }); | |
if (delegateUserInput) delegateUserInput.value = ''; | |
if(chkFullAccess) chkFullAccess.checked = false; if(chkSendAs) chkSendAs.checked = false; | |
if(chkSendOnBehalf) chkSendOnBehalf.checked = false; if(chkAutoMapping) chkAutoMapping.checked = false; | |
if(automapOptionDiv) automapOptionDiv.style.display = 'none'; | |
delFwd_stagedDelegationActions = []; | |
if (typeof delFwd_updateForwardingControlsState === 'function') delFwd_updateForwardingControlsState(); | |
document.getElementById('stageDelegationForRemovalBtn').disabled = true; | |
document.getElementById('stageDelegationChangeBtn').disabled = true; | |
document.getElementById('saveDelFwdChangesBtn').disabled = true; | |
} | |
function delFwd_openModal(showDelegationTabFirst = true) { | |
const upnFromManageModal = document.getElementById('manageModalUserUpn').textContent; | |
const delegationTabBtn = document.getElementById('delegationTabBtn'); const forwardingTabBtn = document.getElementById('forwardingTabBtn'); | |
delFwd_resetModal(); | |
if (showDelegationTabFirst) { if(delegationTabBtn) delegationTabBtn.click(); } else { if(forwardingTabBtn) forwardingTabBtn.click(); } | |
if (upnFromManageModal && !upnFromManageModal.startsWith('[')) { | |
const userUpnSpan = document.getElementById('delFwdUserUpn'); if(userUpnSpan) userUpnSpan.textContent = upnFromManageModal; | |
const statusContainer = document.getElementById('delFwdStatusContainer'); if(statusContainer) statusContainer.style.display = 'block'; | |
const statusMessage = document.getElementById('delFwdStatusMessage'); if(statusMessage) statusMessage.textContent = 'Loading current settings...'; | |
fetch('/getdelegationandforwardingsettings?upn=' + encodeURIComponent(upnFromManageModal)) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
delFwd_populateUI(data); | |
document.getElementById('stageDelegationChangeBtn').disabled = false; | |
document.getElementById('stageDelegationForRemovalBtn').disabled = false; | |
document.getElementById('saveDelFwdChangesBtn').disabled = false; | |
if(statusContainer) statusContainer.style.display = 'none'; | |
}) | |
.catch(error => { if(statusMessage) statusMessage.textContent = 'Failed to load settings: ' + error.message; }); | |
} else { const userUpnSpan = document.getElementById('delFwdUserUpn'); if(userUpnSpan) userUpnSpan.textContent = '[No User Loaded]'; } | |
document.getElementById('manageDelegationForwardingModal').style.display = 'block'; | |
} | |
function delFwd_refreshStagedDisplay() { | |
const listbox = document.getElementById('stagedDelegationChangesList'); if(!listbox) return; | |
listbox.innerHTML = ''; | |
delFwd_stagedDelegationActions.forEach((item, index) => { | |
let text = '[' + item.action.toUpperCase() + '] ' + item.type + ' for ' + item.delegate; | |
if (item.type === 'FullAccess' && item.action === 'add') { text += ' (Automap: ' + (item.automap ? 'Yes' : 'No') + ')'; } | |
listbox.add(new Option(text, index)); | |
}); | |
} | |
// --- 4. Event Listeners --- | |
document.getElementById('delegationTabBtn')?.addEventListener('click', () => { document.getElementById('delegationTabBtn').classList.add('active-tab'); document.getElementById('forwardingTabBtn').classList.remove('active-tab'); document.getElementById('delegationsContentDiv').classList.add('active-content'); document.getElementById('forwardingContentDiv').classList.remove('active-content'); }); | |
document.getElementById('forwardingTabBtn')?.addEventListener('click', () => { document.getElementById('forwardingTabBtn').classList.add('active-tab'); document.getElementById('delegationTabBtn').classList.remove('active-tab'); document.getElementById('forwardingContentDiv').classList.add('active-content'); document.getElementById('delegationsContentDiv').classList.remove('active-content'); }); | |
document.getElementById('manageDelegateAccessBtn')?.addEventListener('click', () => delFwd_openModal(true)); | |
document.getElementById('manageSetupForwardingBtn')?.addEventListener('click', () => delFwd_openModal(false)); | |
document.getElementById('closeDelFwdModalBtn')?.addEventListener('click', () => { document.getElementById('manageDelegationForwardingModal').style.display = 'none'; }); | |
document.getElementById('chkFullAccess')?.addEventListener('change', () => { const div = document.getElementById('automapOptionDiv'); if(div) div.style.display = document.getElementById('chkFullAccess').checked ? 'block' : 'none'; }); | |
document.getElementById('fwdStateEnableRadio')?.addEventListener('change', delFwd_updateForwardingControlsState); | |
document.getElementById('fwdStateDisableRadio')?.addEventListener('change', delFwd_updateForwardingControlsState); | |
document.getElementById('pickDelegateUserBtn')?.addEventListener('click', () => { | |
const query = prompt("Search for a user to delegate permissions to:"); if (!query) return; | |
const statusMsg = document.getElementById('delFwdStatusMessage'); const statusContainer = document.getElementById('delFwdStatusContainer'); | |
statusMsg.textContent = 'Searching...'; statusContainer.style.display = 'block'; | |
fetch('/search?query=' + encodeURIComponent(query)).then(res => res.json()).then(results => { | |
if (results.error || results.length === 0) { throw new Error(results.error || 'No users found.'); } | |
let msg = "Select a user:\n" + results.map((u,i) => (i+1)+". "+u.displayName+" ("+u.upn+")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { document.getElementById('delegateUserInput').value = results[choice-1].upn; statusContainer.style.display = 'none'; } | |
}).catch(err => { statusMsg.textContent = 'User search failed: ' + err.message; }); | |
}); | |
document.getElementById('stageDelegationChangeBtn')?.addEventListener('click', () => { | |
const delegateUserInput = document.getElementById('delegateUserInput'); const delegate = delegateUserInput.value; if (!delegate) { alert('Please pick a delegate user first.'); return; } | |
const permissions = []; | |
if (document.getElementById('chkFullAccess').checked) permissions.push({ type: 'FullAccess', automap: document.getElementById('chkAutoMapping').checked }); | |
if (document.getElementById('chkSendAs').checked) permissions.push({ type: 'SendAs' }); | |
if (document.getElementById('chkSendOnBehalf').checked) permissions.push({ type: 'SendOnBehalf' }); | |
if (permissions.length === 0) { alert('Please select at least one permission type.'); return; } | |
permissions.forEach(perm => { delFwd_stagedDelegationActions.push({ action: 'add', delegate: delegate, type: perm.type, automap: perm.automap }); }); | |
delFwd_refreshStagedDisplay(); | |
delegateUserInput.value = ''; ['chkFullAccess', 'chkSendAs', 'chkSendOnBehalf', 'chkAutoMapping'].forEach(id => { const el = document.getElementById(id); if(el) el.checked = false; }); document.getElementById('automapOptionDiv').style.display = 'none'; | |
}); | |
document.getElementById('stageDelegationForRemovalBtn')?.addEventListener('click', () => { | |
[{ list: document.getElementById('currentFullAccessList'), type: 'FullAccess' }, { list: document.getElementById('currentSendAsList'), type: 'SendAs' }, { list: document.getElementById('currentSendOnBehalfList'), type: 'SendOnBehalf' }].forEach(obj => { | |
if(!obj.list) return; | |
Array.from(obj.list.selectedOptions).forEach(opt => { | |
const alreadyStaged = delFwd_stagedDelegationActions.some(item => item.action === 'remove' && item.delegate === opt.value && item.type === obj.type); | |
if (!alreadyStaged) { delFwd_stagedDelegationActions.push({ action: 'remove', delegate: opt.value, type: obj.type }); opt.style.textDecoration = 'line-through'; opt.style.color = '#aaa'; } | |
}); | |
}); | |
delFwd_refreshStagedDisplay(); | |
}); | |
document.getElementById('unstageSelectedDelegationBtn')?.addEventListener('click', () => { | |
Array.from(document.getElementById('stagedDelegationChangesList').selectedOptions).reverse().forEach(opt => { | |
const indexToRemove = parseInt(opt.value, 10); | |
const actionItem = delFwd_stagedDelegationActions[indexToRemove]; | |
if (actionItem && actionItem.action === 'remove') { | |
[document.getElementById('currentFullAccessList'), document.getElementById('currentSendAsList'), document.getElementById('currentSendOnBehalfList')].forEach(list => { | |
if(!list) return; Array.from(list.options).forEach(listOpt => { if (listOpt.value === actionItem.delegate) { listOpt.style.textDecoration = ''; listOpt.style.color = ''; } }); | |
}); | |
} | |
delFwd_stagedDelegationActions.splice(indexToRemove, 1); | |
}); | |
delFwd_refreshStagedDisplay(); | |
}); | |
document.getElementById('pickForwardToUserBtn')?.addEventListener('click', () => { | |
const query = prompt("Search for a user/mailbox to forward to:"); if (!query) return; | |
const delFwdStatusContainer = document.getElementById('delFwdStatusContainer'); const delFwdStatusMessage = document.getElementById('delFwdStatusMessage'); | |
delFwdStatusMessage.textContent = 'Searching...'; delFwdStatusContainer.style.display = 'block'; | |
fetch('/search?query=' + encodeURIComponent(query)).then(res => res.json()).then(results => { | |
if (results.error || results.length === 0) { throw new Error(results.error || 'No users found.'); } | |
let msg = "Select a recipient:\n" + results.map((u,i) => (i+1)+". "+u.displayName+" ("+u.upn+")").join("\n"); | |
const choice = parseInt(prompt(msg), 10); | |
if (choice > 0 && choice <= results.length) { document.getElementById('forwardToUserInput').value = results[choice-1].upn; delFwdStatusContainer.style.display = 'none'; } | |
}).catch(err => { delFwdStatusMessage.textContent = 'Recipient search failed: ' + err.message; }); | |
}); | |
document.getElementById('saveDelFwdChangesBtn')?.addEventListener('click', () => { | |
const upn = document.getElementById('delFwdUserUpn').textContent; const statusContainer = document.getElementById('delFwdStatusContainer'); const statusMessage = document.getElementById('delFwdStatusMessage'); | |
if (!upn || upn.startsWith('[')) { if(statusMessage) statusMessage.textContent = 'No user loaded.'; if(statusContainer) statusContainer.style.display = 'block'; return; } | |
// --- Determine if forwarding settings have changed --- | |
let hasForwardingChanges = false; | |
const forwardingIsEnabled = document.getElementById('fwdStateEnable').checked; | |
const newForwardToAddress = document.getElementById('forwardToUserInput').value.trim(); | |
const newDeliverAndForwardState = document.getElementById('chkDeliverToMailboxAndForward').checked; | |
// A change is detected if the enable/disable state is different than before, OR if it's enabled and the details have changed. | |
const wasEnabled = !!delFwd_originalForwardingSettings.forwardingSmtpAddress; | |
if (forwardingIsEnabled !== wasEnabled) { | |
hasForwardingChanges = true; | |
} else if (forwardingIsEnabled) { // if state is enabled and hasn't changed, check if details changed | |
if (newForwardToAddress.toLowerCase() !== (delFwd_originalForwardingSettings.forwardingSmtpAddress || '').toLowerCase() || | |
newDeliverAndForwardState !== delFwd_originalForwardingSettings.deliverToMailboxAndForward) { | |
hasForwardingChanges = true; | |
} | |
} | |
// Only validate forwarding input if a forwarding change is being made. | |
if (hasForwardingChanges && forwardingIsEnabled && !newForwardToAddress) { | |
alert('Please pick a user/mailbox to forward to.'); | |
return; | |
} | |
const finalDelegations = { add: [], remove: [] }; | |
delFwd_stagedDelegationActions.forEach(item => { if (item.action === 'add') { finalDelegations.add.push(item); } else { finalDelegations.remove.push(item); } }); | |
const hasDelegationChanges = finalDelegations.add.length > 0 || finalDelegations.remove.length > 0; | |
if (!hasDelegationChanges && !hasForwardingChanges) { if (statusMessage) statusMessage.textContent = 'No changes to save.'; if (statusContainer) statusContainer.style.display = 'block'; return; } | |
let confirmMsg = 'You are about to apply the following for ' + upn + ':\n\n'; | |
if (hasDelegationChanges) { confirmMsg += 'Delegations:\n'; finalDelegations.add.forEach(d => confirmMsg += ' ADD ' + d.type + ' to ' + d.delegate + (d.type==='FullAccess' ? ' (Automap: '+(d.automap?'Yes':'No')+')':'') + '\n'); finalDelegations.remove.forEach(d => confirmMsg += ' REMOVE ' + d.type + ' from ' + d.delegate + '\n'); } | |
if (hasForwardingChanges) { | |
const forwardingChangesPayload = { enabled: forwardingIsEnabled, forwardTo: forwardingIsEnabled ? newForwardToAddress : null, deliverToMailboxAndForward: forwardingIsEnabled ? newDeliverAndForwardState : false }; | |
confirmMsg += '\nForwarding:\n'; if (forwardingChangesPayload.enabled) { confirmMsg += ' Enable forwarding to: ' + forwardingChangesPayload.forwardTo + '\n'; confirmMsg += ' Deliver to mailbox and forward: ' + (forwardingChangesPayload.deliverToMailboxAndForward ? 'Yes' : 'No') + '\n'; } else { confirmMsg += ' Disable forwarding.\n'; } | |
} | |
confirmMsg += '\nProceed?'; | |
if (confirm(confirmMsg)) { | |
const payload = { upn: upn, delegations: finalDelegations, forwarding: hasForwardingChanges ? { enabled: forwardingIsEnabled, forwardTo: newForwardToAddress, deliverToMailboxAndForward: newDeliverAndForwardState } : null }; | |
if (statusMessage) statusMessage.textContent = 'Saving changes...'; if (statusContainer) statusContainer.style.display = 'block'; | |
fetch('/updatedelegationandforwardingsettings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) | |
.then(res => res.json()) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
updateStatusMessage(data.status, 'success'); | |
playNotificationSound(); | |
document.getElementById('manageDelegationForwardingModal').style.display = 'none'; | |
if (hasForwardingChanges || hasDelegationChanges) { | |
alert("Settings were saved successfully.\n\nIt may take a few moments for changes to be reflected.\n\nThe dashboard will reload after a short delay."); | |
updateStatusMessage("Waiting 10 seconds for service replication before reloading...", 'info'); | |
setTimeout(() => { if (document.getElementById('loadUserBtn')) document.getElementById('loadUserBtn').click(); }, 10000); | |
} | |
}) | |
.catch(error => { if (statusMessage) statusMessage.textContent = 'Save failed: ' + error.message; }); | |
} | |
}); | |
// --- Stop Server - Listener for the "Stop Server & Detach UI" button in Settings ---------------------------------------------------------------------------------------------------------------------------------------------- | |
const stopServerBtn = document.getElementById('stopServerBtn'); | |
if (stopServerBtn) { | |
stopServerBtn.addEventListener('click', () => { | |
const confirmMessage = "Are you sure you want to stop the web server?\n\n- The UI in this browser tab will become unresponsive.\n- The PowerShell console will become interactive.\n- Your M365 connections will remain active in that PowerShell session."; | |
if (confirm(confirmMessage)) { | |
// Send the shutdown signal to the backend. | |
fetch('/shutdown') | |
.catch(error => { | |
// Error is expected as the server closes the connection. | |
console.log("Shutdown signal sent. Browser connection was closed by the server, which is normal.", error); | |
}); | |
// Update the main status strip and disable all the main action buttons to indicate the UI is detached. | |
updateStatusMessage('SERVER STOPPED: UI is now detached. The backend PowerShell console is active.', 'error'); | |
// Disable all top-level buttons to prevent further actions | |
const topButtons = ['searchBtn', 'loadUserBtn', 'clearUserBtn', 'backupUserBtn', 'captureUserDetailsBtn', 'openPortalBtn', 'runPsCommandBtn', 'manageUserBtn', 'connectAllBtn', 'disconnectAllBtn', 'graph', 'exchange', 'teams', 'settingsBtn']; | |
topButtons.forEach(id => { | |
const btn = document.getElementById(id); | |
if (btn) { | |
btn.disabled = true; | |
btn.style.opacity = '0.5'; | |
btn.style.cursor = 'not-allowed'; | |
} | |
}); | |
alert("Server has been stopped. The UI is now read-only. You can use the PowerShell console."); | |
} | |
}); | |
} | |
// --- Global - Helper function to generate username/alias formats --- | |
function generateUsernameFormats(firstName, lastName) { | |
if (!firstName && !lastName) return []; | |
const fn = (firstName || '').toLowerCase().replace(/[^a-z0-9]/g, ''); | |
const ln = (lastName || '').toLowerCase().replace(/[^a-z0-9]/g, ''); | |
if (!fn && !ln) return []; | |
const f = fn; const l = ln; | |
const fi = fn ? fn.charAt(0) : ''; const li = ln ? ln.charAt(0) : ''; | |
const formats = new Set(); | |
if (f && l) { | |
formats.add(f + '.' + l); formats.add(f + l); formats.add(f + l + fi); | |
formats.add(f + '.' + li); formats.add(fi + l); formats.add(fi + '.' + l); | |
formats.add(l + '.' + f); formats.add(l + f); formats.add(l + f + fi); | |
formats.add(l + '.' + fi); formats.add(li + f); formats.add(li + '.' + f); | |
} | |
if (f) formats.add(f); | |
if (l) formats.add(l); | |
return Array.from(formats); | |
} | |
// --- Onboarding - Onboarding Username Suggestions Logic (Custom Dropdown) --- | |
const onboardFirstNameInput = document.getElementById('onboardFirstName'); | |
const onboardLastNameInput = document.getElementById('onboardLastName'); | |
const onboardUsernameInput = document.getElementById('onboardUsername'); | |
const onboardUsernameDropdown = document.getElementById('onboard-username-custom-dropdown'); | |
const showUsernameSuggestions = () => { | |
if (!onboardFirstNameInput || !onboardLastNameInput || !onboardUsernameDropdown) return; | |
const suggestions = generateUsernameFormats(onboardFirstNameInput.value, onboardLastNameInput.value); | |
onboardUsernameDropdown.innerHTML = ''; | |
if (suggestions.length === 0) { | |
onboardUsernameDropdown.style.display = 'none'; | |
return; | |
} | |
suggestions.forEach(suggestion => { | |
const item = document.createElement('div'); | |
item.textContent = suggestion; | |
item.addEventListener('mousedown', (e) => { | |
e.preventDefault(); | |
if (onboardUsernameInput) onboardUsernameInput.value = suggestion; | |
onboardUsernameDropdown.style.display = 'none'; | |
}); | |
onboardUsernameDropdown.appendChild(item); | |
}); | |
onboardUsernameDropdown.style.display = 'block'; | |
}; | |
const hideUsernameSuggestions = () => { | |
// A small delay allows a click on a suggestion to register before the dropdown is hidden | |
setTimeout(() => { | |
if (onboardUsernameDropdown) onboardUsernameDropdown.style.display = 'none'; | |
}, 200); | |
}; | |
// Show the dropdown when the user focuses on the username input OR types in the name fields | |
onboardUsernameInput?.addEventListener('focus', showUsernameSuggestions); | |
onboardFirstNameInput?.addEventListener('input', showUsernameSuggestions); | |
onboardLastNameInput?.addEventListener('input', showUsernameSuggestions); | |
// Hide the dropdown after clicking away from the username input | |
onboardUsernameInput?.addEventListener('blur', hideUsernameSuggestions); | |
// --- Hide/Show Mailbox - Listener for Hide/Show Mailbox (GAL Visibility) button --------------------------------------------------------------------------------------------------------------- | |
const toggleGalBtn = document.getElementById('manageToggleGalVisibilityBtn'); | |
if (toggleGalBtn) { | |
toggleGalBtn.addEventListener('click', () => { | |
const upn = document.getElementById('manageModalUserUpn').textContent; | |
if (!upn || upn.startsWith('[')) { | |
showManageModalStatus("Please load a user first.", "error"); | |
return; | |
} | |
showManageModalStatus("Checking current GAL visibility status...", "info"); | |
// Get the current state | |
fetch('/getgalvisibility?upn=' + encodeURIComponent(upn)) | |
.then(res => { | |
if (!res.ok) return res.json().then(err => { throw new Error(err.error || 'Check failed'); }); | |
return res.json(); | |
}) | |
.then(data => { | |
if(data.error) throw new Error(data.error); | |
const isCurrentlyHidden = data.isHidden; | |
const newState = !isCurrentlyHidden; // The state we want to set | |
const actionText = isCurrentlyHidden ? "show (unhide)" : "hide"; | |
if (confirm("This will " + actionText + " the mailbox for " + upn + " in the Global Address List.\n\nAre you sure?")) { | |
showManageModalStatus("Updating GAL visibility for " + upn + "...", "info"); | |
// Step 2: Call the update endpoint with the desired new state | |
const payload = { upn: upn, newState: newState }; | |
return fetch('/togglegalvisibility', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
} else { | |
showManageModalStatus("Operation cancelled.", "info"); | |
return Promise.reject('cancelled'); | |
} | |
}) | |
.then(response => { | |
if (!response) return; | |
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'Unknown error'); }); | |
return response.json(); | |
}) | |
.then(result => { | |
if (!result) return; | |
if (result.error) throw new Error(result.error); | |
updateStatusMessage(result.status, 'success'); | |
showManageModalStatus(result.status, 'success'); | |
playNotificationSound(); | |
setTimeout(() => { | |
if(document.getElementById('usersearch').value === upn) document.getElementById('loadUserBtn').click(); | |
}, 1000); // Reload user | |
}) | |
.catch(error => { | |
if (error !== 'cancelled') { | |
showManageModalStatus("Failed: " + error.message, 'error'); | |
} | |
}); | |
}); | |
} | |
const showEditUsernameSuggestions = () => { | |
const firstNameInput = document.getElementById('edit_GivenName'); | |
const lastNameInput = document.getElementById('edit_Surname'); | |
const usernameDropdown = document.getElementById('edit-username-custom-dropdown'); | |
const usernameInput = document.getElementById('edit_UserPrincipalName_User'); | |
if (!firstNameInput || !lastNameInput || !usernameDropdown || !usernameInput) return; | |
const suggestions = generateUsernameFormats(firstNameInput.value, lastNameInput.value); | |
usernameDropdown.innerHTML = ''; | |
if (suggestions.length === 0) { | |
usernameDropdown.style.display = 'none'; | |
return; | |
} | |
suggestions.forEach(suggestion => { | |
const item = document.createElement('div'); | |
item.textContent = suggestion; | |
item.addEventListener('mousedown', (e) => { | |
e.preventDefault(); | |
usernameInput.value = suggestion; | |
usernameDropdown.style.display = 'none'; | |
}); | |
usernameDropdown.appendChild(item); | |
}); | |
usernameDropdown.style.display = 'block'; | |
}; | |
const hideEditUsernameSuggestions = () => { | |
// A small delay allows a click on a suggestion to register before the dropdown is hidden | |
setTimeout(() => { | |
const usernameDropdown = document.getElementById('edit-username-custom-dropdown'); | |
if (usernameDropdown) usernameDropdown.style.display = 'none'; | |
}, 200); | |
}; | |
const editFirstNameInput = document.getElementById('edit_GivenName'); | |
const editLastNameInput = document.getElementById('edit_Surname'); | |
const editUsernameInput = document.getElementById('edit_UserPrincipalName_User'); | |
if(editFirstNameInput) editFirstNameInput.addEventListener('input', showEditUsernameSuggestions); | |
if(editLastNameInput) editLastNameInput.addEventListener('input', showEditUsernameSuggestions); | |
if(editUsernameInput) editUsernameInput.addEventListener('focus', showEditUsernameSuggestions); | |
if(editUsernameInput) editUsernameInput.addEventListener('blur', hideEditUsernameSuggestions); | |
// --- GROUP DETAILS MODAL LOGIC -------------------------------------------------------------------------------------------------------------------------------------------------------- | |
const groupDetailsModal = document.getElementById('groupDetailsModal'); | |
const closeGroupDetailsModalBtn = document.getElementById('closeGroupDetailsModalBtn'); | |
function openGroupDetailsModal(groupName) { | |
if (!groupDetailsModal || !groupName) return; | |
const statusContainer = document.getElementById('groupDetailsStatusContainer'); | |
const statusMessage = document.getElementById('groupDetailsStatusMessage'); | |
// Reset fields and show loading message | |
document.getElementById('groupDetail_DisplayName').textContent = groupName; | |
document.getElementById('groupDetail_Mail').textContent = 'Loading...'; | |
document.getElementById('groupDetail_GroupTypes').textContent = 'Loading...'; | |
document.getElementById('groupDetail_Visibility').textContent = 'Loading...'; | |
document.getElementById('groupDetail_Owners').textContent = 'Loading...'; | |
document.getElementById('groupDetail_MemberList').innerHTML = '<li>Loading...</li>'; | |
document.getElementById('groupDetail_MemberCount').textContent = '...'; | |
document.getElementById('groupDetail_DynamicRuleRow').style.display = 'none'; | |
document.getElementById('groupDetail_MembershipRule').textContent = ''; | |
statusMessage.textContent = 'Fetching details for "' + groupName + '"...'; | |
statusContainer.style.display = 'block'; | |
groupDetailsModal.style.display = 'block'; | |
fetch('/getgroupdetails?groupName=' + encodeURIComponent(groupName)) | |
.then(res => { | |
if (!res.ok) return res.json().then(err => { throw new Error(err.error || 'Server error'); }); | |
return res.json(); | |
}) | |
.then(data => { | |
if (data.error) throw new Error(data.error); | |
// properties are PascalCase. | |
const members = data.Members; | |
const owners = data.Owners; | |
const memberCount = data.MemberCount; | |
document.getElementById('groupDetail_Mail').textContent = data.Mail || '[Not Available]'; | |
document.getElementById('groupDetail_GroupTypes').textContent = data.GroupTypes || '[N/A]'; | |
document.getElementById('groupDetail_Visibility').textContent = data.Visibility || '[N/A]'; | |
document.getElementById('groupDetail_Owners').textContent = (owners && owners.length > 0) ? owners.join(', ') : 'None'; | |
document.getElementById('groupDetail_MemberCount').textContent = memberCount || 0; | |
if (data.IsDynamic) { | |
document.getElementById('groupDetail_DynamicRuleRow').style.display = 'table-row'; | |
document.getElementById('groupDetail_MembershipRule').textContent = data.MembershipRule || '[Rule not defined]'; | |
} | |
const memberListDiv = document.getElementById('groupDetail_MemberList'); | |
memberListDiv.innerHTML = ''; | |
if (members && members.length > 0) { | |
members.forEach(member => { | |
const memberDiv = document.createElement('div'); | |
memberDiv.textContent = member; | |
memberListDiv.appendChild(memberDiv); | |
}); | |
} else { | |
memberListDiv.textContent = 'No members found in this group.'; | |
} | |
statusContainer.style.display = 'none'; | |
}) | |
.catch(error => { | |
statusMessage.textContent = 'Failed to load group details: ' + error.message; | |
}); | |
} | |
// --- Group Details - Close button ---- | |
if (closeGroupDetailsModalBtn) { | |
closeGroupDetailsModalBtn.addEventListener('click', () => { | |
groupDetailsModal.style.display = 'none'; | |
}); | |
} | |
// Double-click listeners to the three group listboxes in the Manage Groups modal | |
const groupListBoxIds = ['currentGroupsList', 'loadedCopyUserGroupsList', 'addGroupsList']; | |
groupListBoxIds.forEach(id => { | |
const listbox = document.getElementById(id); | |
if (listbox) { | |
listbox.addEventListener('dblclick', (event) => { | |
if (event.target.tagName === 'OPTION' && event.target.value) { | |
openGroupDetailsModal(event.target.value); | |
} | |
}); | |
} | |
}); | |
// --- Group Details - COPY GROUP MEMBERS BUTTON --- | |
const copyGroupMembersBtn = document.getElementById('copyGroupMembersBtn'); | |
if (copyGroupMembersBtn) { | |
copyGroupMembersBtn.addEventListener('click', () => { | |
const memberListContainer = document.getElementById('groupDetail_MemberList'); | |
// The member list is populated with individual <div> elements | |
const memberElements = memberListContainer.querySelectorAll('div'); | |
if (memberElements.length === 0) { | |
updateStatusMessage('No members to copy.', 'info'); | |
return; | |
} | |
// Create an array of just the names | |
const memberNames = Array.from(memberElements).map(el => el.textContent); | |
// Join the array into a single string with each name on a new line | |
const textToCopy = memberNames.join('\n'); | |
navigator.clipboard.writeText(textToCopy).then(() => { | |
updateStatusMessage(memberNames.length + ' members copied to clipboard!', 'success'); | |
playNotificationSound(); | |
}).catch(err => { | |
updateStatusMessage('Failed to copy members: ' + err.message, 'error'); | |
console.error('Clipboard copy failed: ', err); | |
}); | |
}); | |
} | |
const closeSearchBtn = document.getElementById('closeSearchModalBtn'); | |
if (closeSearchBtn) { | |
closeSearchBtn.addEventListener('click', () => { | |
document.getElementById('searchModal').style.display = 'none'; | |
}); | |
} | |
const searchPrevBtn = document.getElementById('searchPrevBtn'); | |
if(searchPrevBtn) { | |
searchPrevBtn.addEventListener('click', () => { | |
if (searchCurrentPage > 1) { | |
searchCurrentPage--; | |
renderSearchPage(); | |
} | |
}); | |
} | |
const searchNextBtn = document.getElementById('searchNextBtn'); | |
if(searchNextBtn) { | |
searchNextBtn.addEventListener('click', () => { | |
if ((searchCurrentPage * searchPageSize) < fullSearchResultSet.length) { | |
searchCurrentPage++; | |
renderSearchPage(); | |
} | |
}); | |
} | |
// --- Advance Search - Listeners --- | |
const searchToggleBtn = document.getElementById('searchToggleAdvancedBtn'); | |
const advancedPanel = document.getElementById('advancedSearchPanel'); | |
if (searchToggleBtn && advancedPanel) { | |
searchToggleBtn.addEventListener('click', () => { | |
const isHidden = advancedPanel.style.display === 'none'; | |
advancedPanel.style.display = isHidden ? 'block' : 'none'; | |
searchToggleBtn.textContent = isHidden ? 'Advanced Search ▲' : 'Advanced Search ▼'; | |
}); | |
} | |
const advancedSearchBtn = document.getElementById('advancedSearchBtn'); | |
if(advancedSearchBtn) { | |
advancedSearchBtn.addEventListener('click', () => { | |
const searchField = document.getElementById('searchFieldSelect').value; | |
const searchString = document.getElementById('advancedSearchInput').value; | |
if (!searchString) { | |
alert('Please enter text in the advanced search box.'); | |
return; | |
} | |
executeUserSearch(searchString, searchField); | |
}); | |
} | |
const advancedSearchInputForEnter = document.getElementById('advancedSearchInput'); | |
if (advancedSearchInputForEnter) { | |
advancedSearchInputForEnter.addEventListener('keypress', function(e) { | |
if (e.key === 'Enter') { | |
e.preventDefault(); // Prevent any default browser action | |
// Programmatically click the advanced search button | |
document.getElementById('advancedSearchBtn')?.click(); | |
} | |
}); | |
} | |
const clearSearchBtn = document.getElementById('clearSearchBtn'); | |
if(clearSearchBtn) { | |
clearSearchBtn.addEventListener('click', () => { | |
resetSearchModal(); | |
document.getElementById('advancedSearchInput').focus(); | |
}); | |
} | |
const searchResultsHeader = document.querySelector('.search-results-table thead'); | |
if (searchResultsHeader) { | |
searchResultsHeader.addEventListener('click', (event) => { | |
const header = event.target.closest('.sortable-header'); | |
if (!header) return; // Exit if the click was not on a sortable header | |
const newSortColumn = header.dataset.sortkey; | |
if (sortColumn === newSortColumn) { | |
// If clicking the same column, reverse the direction | |
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; | |
} else { | |
// If clicking a new column, set it and default to ascending | |
sortColumn = newSortColumn; | |
sortDirection = 'asc'; | |
} | |
// Sort the data and redraw the table | |
sortAndRerender(); | |
}); | |
} | |
clearUserDetails(); | |
}); | |
</script> | |
</head> | |
<body> | |
<div class="button-bar"> | |
<div class="single-button-group"> | |
<button id="connectAllBtn" title="Connect to all services sequentially (Graph, Exchange, Teams)">🔗</button> | |
</div> | |
<div class="connection-buttons-group"> | |
<button id="graph" onclick="handleSingleConnectClick('graph')" title="Connect to Microsoft Graph API">Graph</button> | |
<button id="exchange" onclick="handleSingleConnectClick('exchange')" title="Connect to Exchange Online">Exchange</button> | |
<button id="teams" onclick="handleSingleConnectClick('teams')" title="Connect to Microsoft Teams">Teams</button> | |
<button onclick="showStatus()" title="Show current connection status in console">Status</button> | |
</div> | |
<div class="user-search-group"> | |
<div style="position: relative;"> <input type="text" id="usersearch" placeholder="Enter user..."> | |
<div id="searchHistoryDropdown" class="custom-dropdown-content"></div> | |
</div> | |
<button id="mainSearchBtn" title="Search for users by UPN, email, or display name">Search</button> | |
<button id="loadUserBtn" title="Load full details for the selected user">Load</button> | |
<button id="clearAllBtn" title="Clear all user details from the dashboard">Clear</button> | |
</div> | |
<button id="backupUserDetailsBtn" title="Backup the currently loaded user's details to a JSON file">💾</button> | |
<button id="openPortalBtn" title="Open various M365 admin portals in a new tab">🌐</button> | |
<button id="captureDetailsBtn" title="Capture the current user details view as an image and copy to clipboard">📷</button> | |
<button id="settingsBtn" title="Open application settings">⚙️</button> | |
$(if (-not $effectiveViewOnlyMode) { | |
@" | |
<button id="runPsCommandBtn" title="Execute a PowerShell command in the backend console (Use with caution!)">PowerShell</button> | |
<button id="manageUserBtn" title="Perform management actions on the loaded user">Manage</button> | |
"@ | |
} else { | |
@" | |
<button id="runPsCommandBtn" style="display:none;" title="Execute a PowerShell command in the backend console (Use with caution!)">PowerShell</button> | |
<button id="manageUserBtn" style="display:none;" title="Perform management actions on the loaded user">Manage</button> | |
"@ | |
}) | |
<div class="right-align-group"> | |
<select id="checkModeDropdown" class="action-dropdowns" title="Select a checklist mode to highlight fields for onboarding/offboarding"> | |
<option value="none">Checker: None</option> | |
<option value="onboardingCheck">Onboarding Check</option> | |
<option value="offboardingCheck">Offboarding Check</option> | |
</select> | |
<button id="disconnectAllBtn" onclick="disconnectAll()" title="Disconnect all active M365 service connections">🔌</button> | |
</div> | |
</div> | |
<div id="status_strip_container"> | |
<span id="dashboard_status_message"></span> | |
</div> | |
<hr class="section-divider" /> | |
<div id="captureArea"> | |
<div id="userNameHeader">Firstname Lastname</div> | |
<div id="tenantHeader">Company Name</div> | |
<div id="userDetailsContainer" style="margin-top: 10px;"> | |
<div class="columns-container"> | |
<div class="column"> | |
<div class="info-group"> | |
<h3>Account Details</h3> | |
<div class="label-value-pair"><span class="label">UPN:</span> <span class="value" id="detail_upn"></span></div> | |
<div class="label-value-pair"><span class="label">Title:</span> <span class="value" id="detail_title"></span></div> | |
<div class="label-value-pair"><span class="label">Department:</span> <span class="value" id="detail_department"></span></div> | |
<div class="label-value-pair"><span class="label">Manager:</span> <span class="value" id="detail_manager"></span></div> | |
<div class="label-value-pair"><span class="label">Company:</span> <span class="value" id="detail_companyName"></span></div> | |
<div class="label-value-pair"><span class="label">User Type:</span> <span class="value" id="detail_userType"></span></div> | |
<div class="label-value-pair"><span class="label">GUID:</span> <span class="value" id="detail_userId"></span></div> | |
</div> | |
<div class="info-group"> | |
<h3>Account Status</h3> | |
<div class="label-value-pair"><span class="label">Enabled:</span> <span class="value" id="detail_accountEnabled"></span></div> | |
<div class="label-value-pair"><span class="label">Is Licensed:</span> <span class="value" id="detail_isLicensed"></span></div> | |
<div><span class="label" id="label_licenses">Licenses:</span> <div class="value scrollable-list-short" id="detail_licenses"> </div></div> | |
<div class="label-value-pair"><span class="label">Usage Location:</span> <span class="value" id="detail_usageLocation"></span></div> | |
<div class="label-value-pair"><span class="label">Directory Synced:</span> <span class="value" id="detail_dirSynced"></span></div> | |
</div> | |
<div class="info-group"> | |
<h3>Office / Contact</h3> | |
<div class="label-value-pair"><span class="label">Office Location:</span> <span class="value" id="detail_physicalDeliveryOfficeName"></span></div> | |
<div class="label-value-pair"><span class="label">Street Address:</span> <span class="value" id="detail_streetAddress"></span></div> | |
<div class="label-value-pair"><span class="label">City:</span> <span class="value" id="detail_city"></span></div> | |
<div class="label-value-pair"><span class="label">State/Province:</span> <span class="value" id="detail_state"></span></div> | |
<div class="label-value-pair"><span class="label">Postal/Zip Code:</span> <span class="value" id="detail_postalCode"></span></div> | |
<div class="label-value-pair"><span class="label">Country:</span> <span class="value" id="detail_country"></span></div> | |
<hr style="margin: 6px 0; border-color: #e9ecef;"/> | |
<div class="label-value-pair"><span class="label">Mobile Phone:</span> <span class="value" id="detail_mobilePhone"></span></div> | |
<div class="label-value-pair"><span class="label">Business Phone:</span> <span class="value" id="detail_businessPhones"></span></div> | |
<div class="label-value-pair"><span class="label">Fax Number:</span> <span class="value" id="detail_faxNumber"></span></div> | |
</div> | |
</div> | |
<div class="column"> | |
<div class="info-group"> | |
<h3>Mail Info</h3> | |
<div class="label-value-pair"><span class="label">DisplayName:</span> <span class="value" id="detail_mailDisplayName"></span></div> | |
<div class="label-value-pair"><span class="label">Primary Email:</span> <span class="value" id="detail_mail"></span></div> | |
<div class="label-value-pair"><span class="label">Mail Nickname:</span> <span class="value" id="detail_mailNickname"></span></div> | |
<div class="label-value-pair"><span class="label">Mailbox Type:</span> <span class="value" id="detail_recipientTypeDetails"></span></div> | |
<div class="label-value-pair"><span class="label">Mailbox Usage:</span> <span class="value" id="detail_mailboxQuota"></span></div> | |
<hr style="margin: 6px 0; border-color: #e9ecef;"/> | |
<div class="label-value-pair"><span class="label">Forwarding SMTP:</span> <span class="value" id="detail_forwardingSmtpAddress"></span></div> | |
<div class="label-value-pair"><span class="label">Deliver and Fwd:</span> <span class="value" id="detail_deliverToMailboxAndForward"></span></div> | |
<hr style="margin: 6px 0; border-color: #e9ecef;"/> | |
<div class="label-value-pair"><span class="label">Hide Mailbox:</span> <span class="value" id="detail_hiddenFromAddressLists"></span></div> | |
<div class="label-value-pair"><span class="label">Online Archive:</span> <span class="value" id="detail_archiveStatus"></span></div> | |
<div><span class="label" id="label_aliases">Aliases:</span> <div class="value scrollable-list-medium" id="detail_proxyAddresses"> </div></div> | |
<hr style="margin: 6px 0; border-color: #e9ecef;"/> | |
<div class="label-block-value" id="labelgroup_notes"><span class="label">Notes/Info:</span> <div class="value" id="detail_notes"></div></div> | |
</div> | |
<div class="info-group"> | |
<h3>Delegations</h3> | |
<div id="labelgroup_fullaccess"><span class="label">Full Access:</span> <div class="value scrollable-list-short" id="detail_fullAccessDelegates"> </div></div> | |
<div id="labelgroup_sendas"><span class="label">Send As:</span> <div class="value scrollable-list-short" id="detail_sendAsDelegates"> </div></div> | |
<div id="labelgroup_sendonbehalf"><span class="label">Send on Behalf:</span> <div class="value scrollable-list-short" id="detail_sendOnBehalfDelegates"> </div></div> | |
</div> | |
<div class="info-group"> | |
<h3>Shared Mailbox Access</h3> | |
<div class="loadable-list-group"> | |
<button id="loadSharedMailboxAccessBtn" title="Load list of shared mailboxes this user has Full Access to (can be slow)">Load Shared Access</button> | |
<div class="value scrollable-list-medium" id="detail_userHasFullAccessTo"> </div> | |
</div> | |
</div> | |
</div> | |
<div class="column"> | |
<div class="info-group"> | |
<h3>Group Memberships</h3> | |
<div class="group-controls"> | |
<button id="copyGroupMembershipsBtn" title="Copy the displayed group list to clipboard">Copy Groups</button> | |
<div class="group-sort-options"> | |
<label for="groupSortDropdown">Sort:</label> | |
<select id="groupSortDropdown" class="action-dropdowns" style="font-size:10px; padding: 1px;" title="Change sort order for group memberships"> | |
<option value="az" selected>A>Z</option> | |
<option value="type">Type</option> | |
</select> | |
</div> | |
</div> | |
<textarea id="detail_groupMemberships" readonly title="List of user's group memberships"></textarea> | |
</div> | |
<div class="info-group"> | |
<h3>Automatic Replies</h3> | |
<div id="oof_details_content_wrapper"> <div class="label-value-pair"><span class="label">Status:</span> <span class="value" id="detail_oofStatus"></span></div> | |
<button id="toggleOofDetailsBtn" style="margin-top: 2px; margin-bottom: 5px; font-size: 10px; padding: 2px 5px;" title="Show or hide detailed Out-of-Office settings">View Details</button> | |
<div id="oof_details_content" style="display:none; margin-top: 3px;"> | |
<div class="label-value-pair compact-label-group"><span class="label">External Audience:</span> <span class="value" id="detail_oofExternalAudience"></span></div> | |
<div class="label-value-pair compact-label-group"><span class="label">Scheduled:</span> <span class="value" id="detail_oofScheduled"></span></div> | |
<div class="label-value-pair compact-label-group"><span class="label">Start Time:</span> <span class="value" id="detail_oofStartTime"></span></div> | |
<div class="label-value-pair compact-label-group"><span class="label">End Time:</span> <span class="value" id="detail_oofEndTime"></span></div> | |
<div class="compact-label-group" style="margin-top:5px;"><span class="label">Internal Reply:</span> <pre class="value oof-message" id="detail_oofInternalReply"></pre></div> | |
<div class="compact-label-group" style="margin-top:5px;"><span class="label">External Reply:</span> <pre class="value oof-message" id="detail_oofExternalReply"></pre></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="column" id="column4"> | |
<div class="info-group"> <h3>Teams Calling / Voice</h3> | |
<div class="label-value-pair"><span class="label">Phone Number (Line URI):</span> <span class="value" id="detail_teamsPhoneNumber"></span></div> | |
<div class="label-value-pair"><span class="label">Number Type:</span> <span class="value" id="detail_teamsPhoneType"></span></div> | |
<div class="label-value-pair"><span class="label">Enterprise Voice Enabled:</span> <span class="value" id="detail_teamsEnterpriseVoice"></span></div> | |
<div class="label-value-pair"><span class="label">Teams Calling Policy:</span> <span class="value" id="detail_teamsCallingPolicy"></span></div> | |
<div class="label-value-pair"><span class="label">Teams Upgrade Policy:</span> <span class="value" id="detail_teamsUpgradePolicy"></span></div> | |
<div class="label-value-pair"><span class="label">Tenant Dial Plan:</span> <span class="value" id="detail_teamsTenantDialPlan"></span></div> | |
<div class="label-value-pair"><span class="label">Online Voice Routing Policy:</span> <span class="value" id="detail_teamsOnlineVoiceRoutingPolicy"></span></div> | |
</div> | |
<div class="info-group"> | |
<h3>Devices</h3> | |
<div class="value scrollable-list-medium" id="detail_userDevices" title="List of devices registered to the user"> </div> | |
<hr style="margin-top: 8px;"> | |
</div> | |
<div class="info-group"> | |
<h3>Security & Sign-in</h3> | |
<div id="labelgroup_mfamethods"><span class="label">MFA Registered Methods:</span> <div class="value scrollable-list-short" id="detail_mfaMethods" title="User's registered Multi-Factor Authentication methods"> </div></div> | |
<div class="label-value-pair"><span class="label">PW Last Changed:</span> <span class="value" id="detail_passwordLastChanged"></span></div> | |
<div class="label-value-pair"><span class="label">Last Login:</span> <span class="value" id="detail_lastLogin"></span></div> | |
<div class="label-value-pair"><span class="label">Created Date:</span> <span class="value" id="detail_createdDate"></span></div> | |
</div> | |
<div class="info-group"> <h3>Direct Reports</h3> | |
<div id="direct_reports_content_wrapper"> | |
<div class="label-value-pair"><span class="label">Has Direct Reports:</span> <span class="value" id="detail_directReportsStatus"></span></div> | |
<button id="toggleDirectReportsBtn" style="margin-top: 2px; margin-bottom: 5px; font-size: 10px; padding: 2px 5px;" title="Show or hide list of direct reports">Show</button> | |
<div id="direct_reports_content" style="display:none; margin-top: 3px;"> | |
<div class="value scrollable-list-medium" id="detail_directReportsList" title="List of user's direct reports"> </div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div id="settingsModal" class="modal"> | |
<div class="modal-content"> | |
<h2>Settings</h2> | |
<div class="setting-group"> | |
<label>Access Mode:</label> | |
<input type="radio" id="accessModeRead" name="accessMode" value="read"> | |
<label for="accessModeRead" style="font-weight:normal; display:inline;">Read (View Only)</label><br> | |
<input type="radio" id="accessModeReadWrite" name="accessMode" value="readwrite"> | |
<label for="accessModeReadWrite" style="font-weight:normal; display:inline;">Read & Write</label> | |
</div> | |
<div class="setting-group"> | |
<label>Theme:</label> | |
<input type="radio" id="themeLight" name="theme" value="light"> | |
<label for="themeLight" style="font-weight:normal; display:inline;">Light</label><br> | |
<input type="radio" id="themeDark" name="theme" value="dark"> | |
<label for="themeDark" style="font-weight:normal; display:inline;">Dark</label> | |
</div> | |
<div class="setting-group"> | |
<label for="backupPathSetting">Backup Path: <span class="settings-hint">(Default is MyDocuments\UserDetailsDashboard_Backups if empty)</span></label> | |
<input type="text" id="backupPathSetting" name="backupPath" placeholder="e.g., C:\Data\UserBackups"> | |
</div> | |
<div class="setting-group"> | |
<label for="browserSetting">Launch Browser:</label> | |
<select id="browserSetting" name="browserSetting"> | |
<option value="chrome">Chrome</option> | |
<option value="msedge">Edge</option> | |
<option value="brave">Brave</option> | |
<option value="opera">Opera</option> | |
<option value="firefox">Firefox</option> | |
</select> | |
</div> | |
<div class="setting-group"> | |
<label for="fontSizeSetting">Base Font Size:</label> | |
<select id="fontSizeSetting" name="fontSizeSetting"> | |
<option value="11px">11px</option> | |
<option value="12px">12px</option> | |
<option value="13px">13px (Default)</option> | |
<option value="14px">14px</option> | |
<option value="15px">15px</option> | |
</select> | |
</div> | |
<div class="setting-group"> | |
<label for="fontFamilySetting">Font Style:</label> | |
<select id="fontFamilySetting" name="fontFamilySetting"> | |
<option value="sans-serif">Default (Sans-Serif)</option> | |
<option value="Segoe UI">Segoe UI</option> | |
<option value="Roboto">Roboto</option> | |
</select> | |
</div> | |
<div class="setting-group"> | |
<label for="captureScaleSetting">Screen Capture Scaling: <span class="settings-hint">(Output image size)</span></label> | |
<select id="captureScaleSetting" name="captureScaleSetting"> | |
<option value="0.4">40% (Default)</option> | |
<option value="0.5">50% </option> | |
<option value="0.6">60%</option> | |
<option value="0.7">70%</option> | |
<option value="0.8">80%</option> | |
<option value="0.9">90%</option> | |
<option value="1.0">100% (Full Size)</option> | |
</select> | |
</div> | |
<div class="modal-buttons"> | |
<button id="stopServerBtn" type="button" class="destructive-button" title="Stops the web server, closes this browser tab, and makes the backend PowerShell console interactive.">Stop Server & Detach UI</button> | |
<button id="saveSettingsBtn">Save</button> | |
<button id="closeSettingsModalBtn" type="button">Close</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageModal" class="modal"> | |
<div class="modal-content"> | |
<h2>Manage User: <span id="manageModalUserUpn"></span></h2> | |
<div id="manageModalStatusContainer" class="modal-status-container"> | |
<span id="manageModalStatusMessage"></span> | |
</div> | |
<div style="margin-bottom: 15px;"> | |
<button type="button" id="manageConnectGraphRWBtn" title="Ensure Graph connection is active with ReadWrite permissions (requires Read & Write access mode in main settings)">Ensure Graph ReadWrite Connection</button> | |
<span id="manageGraphConnectStatus"></span> | |
</div> | |
<hr/> | |
<div class="category-buttons" style="margin-top:15px;"> | |
<button type="button" id="manageCatGeneralBtn" title="General user management actions">General</button> | |
<button type="button" id="manageCatOnboardingBtn" title="Create a new user account">Onboarding</button> | |
<button type="button" id="manageCatOffboardingBtn" title="Perform offboarding tasks for the loaded user">Offboarding</button> | |
</div> | |
<div id="manageCatGeneralContent" class="category-content"> | |
<h4>User Profile & Details</h4> | |
<div class="inline-action-with-hint"> | |
<button id="manageEditUserDetailsBtn" title="Modify user properties like title, department, manager, etc.">Edit User Details</button> | |
<span id="editUserSyncStatusHint" class="sync-status-badge" style="display: none;"></span> | |
</div> | |
<h4>Account & Security</h4> | |
<div id="resetPasswordContainer" style="margin-top: 10px; padding:10px; border: 1px dashed #ccc; border-radius:3px; background-color:#f9f9f9;"> | |
<label for="newPasswordInput" style="font-weight:bold; display:block; margin-bottom: 5px;">Reset User Password</label> | |
<div style="display: flex; align-items: center; gap: 10px;"> | |
<input type="password" id="newPasswordInput" placeholder="Enter new password (min. 8 chars)" style="flex-grow: 1; padding: 6px; border-radius: 3px; border: 1px solid #ccc;"> | |
<button id="submitResetPasswordBtn" style="white-space: nowrap;">Submit Reset</button> | |
</div> | |
<div style="margin-top: 5px;"> | |
<input type="checkbox" id="showPasswordCheckbox" style="vertical-align: middle;"> | |
<label for="showPasswordCheckbox" style="font-weight: normal; vertical-align: middle;">Show Password</label> | |
</div> | |
<div style="margin-top: 5px;"> | |
<input type="checkbox" id="resetForceChangePasswordChk" style="vertical-align: middle;"> | |
<label for="resetForceChangePasswordChk" style="font-weight: normal; vertical-align: middle;">Force change on next login</label> | |
</div> | |
</div> | |
<div id="passwordGeneratorArea"> | |
<label for="generatedPassword" style="font-weight:bold;">Password Generator:</label> | |
<span id="generatedPassword" title="Generated password will appear here"> </span> | |
<button id="genPass8" title="Generate 8-character password">8 Chars</button> | |
<button id="genPass12" title="Generate 12-character password">12 Chars</button> | |
<button id="genPass16" title="Generate 16-character password">16 Chars</button> | |
<button id="copyGeneratedPass" title="Copy the generated password to clipboard">Copy</button> | |
</div> | |
<div style="margin-top: 10px;"> | |
<button id="manageToggleAccountBtn" title="Enable or disable the user's account to allow or block sign-in.">Enable/Disable Account</button> | |
<button id="manageRevokeMfaBtn" title="Revoke all Multi-Factor Authentication sessions, requiring the user to re-authenticate everywhere.">Revoke MFA Sessions</button> | |
</div> | |
<h4>Licensing & Groups</h4> | |
<button id="manageLicensesBtn" title="View, add, or remove licenses for the user.">Manage Licenses</button> | |
<button id="manageEditGroupsBtn" title="View, add, or remove group memberships. Includes options to copy groups from another user.">Manage Groups</button> | |
<h4>Mailbox Configuration</h4> | |
<button id="manageEditAliasesBtn" title="View, add, or remove email aliases (proxy addresses) and change the primary SMTP address.">Manage Aliases (SMTP)</button> | |
<button id="manageChangeMailboxTypeBtn" title="Change the mailbox type (e.g., UserMailbox to SharedMailbox).">Change Mailbox Type</button> | |
<button id="manageToggleGalVisibilityBtn" title="Hide or show the user's mailbox from the Global Address List (GAL).">Hide/Show Mailbox</button> | |
<button id="manageEditAutoreplyBtn" title="Configure Out-of-Office (OOF) settings: status, audience, internal/external replies.">Manage Autoreply (OOF)</button> | |
<button id="manageDelegateAccessBtn" title="Manage mailbox delegations: Full Access, Send As, Send on Behalf.">Manage Mailbox Delegations</button> | |
<button id="manageSetupForwardingBtn" title="Set up or modify email forwarding rules for the mailbox.">Manage Email Forwarding</button> | |
<h4>Teams Telephony</h4> | |
<button id="manageEditTeamsPhoneBtn" title="Assign, unassign, or view the user's Teams phone number (DDI/Line URI).">Manage Teams Phone Number</button> | |
<div id="generalActionSubModalPlaceholder" style="margin-top:15px; padding:10px; border:1px solid #eee; display:none;"> | |
</div> | |
</div> | |
<div id="manageCatOnboardingContent" class="category-content"> | |
<form id="onboardingForm"> | |
<h4>Copy Settings</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardCopyUserDisplay">Copy Settings From User (Optional):</label> | |
<div class="inline-picker"> | |
<input type="text" id="onboardCopyUserDisplay" readonly placeholder="No user selected to copy from" title="Displays the UPN of the user whose settings will be copied."> | |
<button type="button" id="onboardPickCopyUserBtn" title="Select an existing user to copy their settings (e.g., groups, licenses, department).">Pick User</button> | |
</div> | |
<label for="onboardCopyUserGroupsDisplay" style="margin-top:5px;">Copied User's Groups (Read-Only):</label> | |
<textarea id="onboardCopyUserGroupsDisplay" rows="3" readonly title="Displays group memberships of the user selected to copy from."></textarea> | |
<label for="onboardCopyUserLicensesDisplay" style="margin-top:5px;">Copied User's Licenses (Read-Only, Friendly Names):</label> | |
<textarea id="onboardCopyUserLicensesDisplay" rows="3" readonly title="Displays licenses of the user selected to copy from."></textarea> | |
</div> | |
<hr/> | |
<h4>Core User Information</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardFirstName">First Name:</label> | |
<input type="text" id="onboardFirstName"> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardLastName">Last Name:</label> | |
<input type="text" id="onboardLastName"> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardDisplayName">Display Name:</label> | |
<input type="text" id="onboardDisplayName" required title="Enter the user's display name (e.g., John Smith)."> | |
</div> | |
<div class="onboarding-form-group" style="position: relative;"> | |
<label for="onboardUsername">Username:</label> | |
<div class="inline-picker"> | |
<input type="text" id="onboardUsername" required title="Enter the username"> | |
<span style="margin: 0 5px;">@</span> | |
<select id="onboardDomainDropdown" title="Select the domain for the UPN."></select> | |
</div> | |
<div id="onboard-username-custom-dropdown" class="custom-dropdown-content"></div> | |
<span id="onboardUpnCheckResult"></span> | |
</div> | |
<h4>Organizational Details</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardJobTitle">Job Title:</label> | |
<input type="text" id="onboardJobTitle" title="Enter the user's job title."> | |
<span id="onboardJobTitle_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardDepartment">Department:</label> | |
<input type="text" id="onboardDepartment" title="Enter the user's department."> | |
<span id="onboardDepartment_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardCompany">Company:</label> | |
<input type="text" id="onboardCompany" title="Enter the user's company name."> | |
<span id="onboardCompany_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardManagerDisplay">Manager:</label> | |
<div class="inline-picker"> | |
<input type="text" id="onboardManagerDisplay" readonly placeholder="No manager selected" title="Displays the UPN of the selected manager."> | |
<span id="onboardManagerDisplay_hint" class="onboarding-hint-text"></span> | |
<button type="button" id="onboardPickManagerBtn" title="Select the user's manager.">Pick Manager</button> | |
</div> | |
</div> | |
<h4>Contact & Location</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardOfficeLocation">Office Location:</label> | |
<input type="text" id="onboardOfficeLocation" title="Enter the user's office location."> | |
<span id="onboardOfficeLocation_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardStreetAddress">Street Address:</label> | |
<input type="text" id="onboardStreetAddress" title="Enter the user's street address."> | |
<span id="onboardStreetAddress_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardCity">City:</label> | |
<input type="text" id="onboardCity" title="Enter the user's city."> | |
<span id="onboardCity_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardState">State/Province:</label> | |
<input type="text" id="onboardState" title="Enter the user's state or province."> | |
<span id="onboardState_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardPostalCode">Postal/Zip Code:</label> | |
<input type="text" id="onboardPostalCode" title="Enter the user's postal or zip code."> | |
<span id="onboardPostalCode_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardCountry">Country:</label> | |
<input type="text" id="onboardCountry" title="Enter the user's country."> | |
<span id="onboardCountry_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardMobilePhone">Mobile Number (for MFA):</label> | |
<input type="text" id="onboardMobilePhone" title="Enter the user's mobile phone number (will be used for MFA registration if provided)."> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardBusinessPhone">Business Phone Number:</label> | |
<input type="text" id="onboardBusinessPhone" title="Enter the user's business phone number."> | |
<span id="onboardBusinessPhone_hint" class="onboarding-hint-text"></span> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardUsageLocation">Usage Location:</label> | |
<input type="text" id="onboardUsageLocation" required title="Enter the two-letter country code for usage location (e.g., US, GB). Required for licensing."> | |
<span id="onboardUsageLocation_hint" class="onboarding-hint-text"></span> | |
</div> | |
<h4>Password</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardPassword">Password:</label> | |
<div style="display: flex; align-items: center; gap: 5px;"> | |
<input type="password" id="onboardPassword" required title="Enter a password or generate one." style="flex-grow: 1;"> | |
<button type="button" id="onboardCopyPasswordBtn" class="small-button">Copy</button> | |
</div> | |
<div style="margin-top: 3px;"> | |
<input type="checkbox" id="onboardShowPasswordChk" style="vertical-align: middle;"> | |
<label for="onboardShowPasswordChk" style="font-weight: normal; vertical-align: middle;">Show Password</label> | |
<input type="checkbox" id="onboardForceChangePasswordChk" style="vertical-align: middle;"> | |
<label for="onboardForceChangePasswordChk" style="font-weight: normal; vertical-align: middle;">Force change on next login</label> | |
</div> | |
<div id="onboardPasswordGeneratorArea" style="margin-top:5px;"> | |
<button type="button" id="onboardGenPass8Btn" title="Generate 8-character password and fill field">Gen 8</button> | |
<button type="button" id="onboardGenPass12Btn" title="Generate 12-character password and fill field">Gen 12</button> | |
<button type="button" id="onboardGenPass16Btn" title="Generate 16-character password and fill field">Gen 16</button> | |
</div> | |
</div> | |
<h4>Groups & Permissions</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardSelectedGroups">Add to Groups:</label> | |
<textarea id="onboardSelectedGroups" rows="3" readonly placeholder="No groups selected" title="Displays groups the new user will be added to."></textarea> | |
<button type="button" id="onboardAddGroupBtn" title="Select groups to add the user to.">Add Groups</button> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardSelectedDelegations">Assign Mailbox Delegation To:</label> | |
<textarea id="onboardSelectedDelegations" rows="3" readonly placeholder="No delegations selected" title="Displays mailboxes this user will get delegate access to."></textarea> | |
<button type="button" id="onboardAddDelegationBtn" title="Select mailboxes to grant this user delegate access (Full Access, Send As, Send on Behalf).">Add Delegation</button> | |
</div> | |
<div class="onboarding-form-group"> | |
<label for="onboardSelectedLicensesDisplay">Assign Licenses:</label> | |
<textarea id="onboardSelectedLicensesDisplay" rows="3" readonly placeholder="No licenses selected" title="Displays licenses that will be assigned to the new user."></textarea> | |
<button type="button" id="onboardPickLicensesBtn" title="Select licenses to assign. Will show available/total counts.">Pick Licenses</button> | |
</div> | |
<h4>Notes</h4> | |
<div class="onboarding-form-group"> | |
<label for="onboardNotes">Add Notes/Info:</label> | |
<textarea id="onboardNotes" rows="3" title="Enter any additional notes for this user."></textarea> | |
</div> | |
<hr style="margin-top:20px; margin-bottom:15px;"/> | |
<button type="submit" id="onboardCreateUserBtn" title="Create the new user account with the specified details. A final review will be shown.">Create User</button> | |
</form> | |
</div> | |
<div id="manageCatOffboardingContent" class="category-content"> | |
<div class="offboarding-top-buttons" style="margin-bottom: 15px;"> | |
<button id="offboardStandardBtn" title="Select standard recommended offboarding tasks.">Standard Offboarding</button> | |
<button id="offboardSelectAllBtn" title="Select all available offboarding tasks.">Select All</button> | |
<button id="offboardUnselectAllBtn" title="Unselect all offboarding tasks.">Unselect All</button> | |
</div> | |
<hr/> | |
<h4>Account & Access</h4> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardBlockSignIn" data-standard="true"><label for="offboardBlockSignIn">Block Sign-in</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardResetPassword" data-standard="true"><label for="offboardResetPassword">Reset Password (16 Chars, Hidden)</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRevokeSessions" data-standard="true"><label for="offboardRevokeSessions">Revoke Sign-in Sessions</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRevokeMfa" data-standard="true"><label for="offboardRevokeMfa">Revoke MFA Sessions</label> | |
</div> | |
<h4>Mailbox & Data</h4> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardConvertToShared" data-standard="true"><label for="offboardConvertToShared">Convert to Shared Mailbox</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardHideFromGAL" data-standard="true"><label for="offboardHideFromGAL">Hide from Global Address List</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardSetupAutoreply"><label for="offboardSetupAutoreply">Setup Autoreply</label> | |
<div class="action-details" id="offboardAutoreplyDetails"> | |
<label for="offboardInternalReply">Internal Reply:</label> | |
<textarea id="offboardInternalReply" rows="3" placeholder="Enter internal OOF message..."></textarea> | |
<label for="offboardExternalReply">External Reply:</label> | |
<textarea id="offboardExternalReply" rows="3" placeholder="Enter external OOF message..."></textarea> | |
<label for="offboardOofAudience">External Audience:</label> | |
<select id="offboardOofAudience"> | |
<option value="None">None</option> | |
<option value="KnownSenders">Known Senders</option> | |
<option value="All">All</option> | |
</select> | |
</div> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardSetupForwarding"><label for="offboardSetupForwarding">Setup Forwarding</label> | |
<div class="action-details" id="offboardForwardingDetails"> | |
<label for="offboardForwardToUpn">Forward to UPN/Email:</label> | |
<input type="text" id="offboardForwardToUpn" placeholder="user@example.com"> | |
<button type="button" id="offboardSearchForwardUserBtn" title="Search for user to forward to">Search User</button> | |
<br><input type="checkbox" id="offboardDeliverAndForward" checked><label for="offboardDeliverAndForward" style="font-weight:normal;">Deliver to Mailbox and Forward</label> | |
</div> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardDelegateAccess"><label for="offboardDelegateAccess">Delegate Mailbox Access</label> | |
<div class="action-details" id="offboardDelegateDetails"> | |
<label for="offboardDelegateToUpnTextarea">Delegate Full Access to (max 3 users, one per line):</label> | |
<textarea id="offboardDelegateToUpnTextarea" rows="3" readonly placeholder="Use 'Pick User' to add delegates..."></textarea> | |
<button type="button" id="offboardPickDelegateBtn" title="Pick a user to add to the delegation list">Pick User to Delegate</button> | |
<button type="button" id="offboardClearDelegatesBtn" title="Clear all delegates from the list above">Clear List</button> | |
</div> | |
</div> | |
<h4>Profile & Resources</h4> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRemoveManager" data-standard="true"><label for="offboardRemoveManager">Remove Manager</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRemoveGroups" data-standard="true"><label for="offboardRemoveGroups">Remove All Group Memberships</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRemoveLicenses" data-standard="true"><label for="offboardRemoveLicenses">Remove All Licenses</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRemoveDDI"><label for="offboardRemoveDDI">Remove DDI/Teams Phone Number</label> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardRemoveDevices"><label for="offboardRemoveDevices">Remove Associated Devices</label> | |
<div class="action-details" id="offboardRemoveDevicesDetails"> | |
<input type="checkbox" id="offboardRemoveMobileOnly" checked><label for="offboardRemoveMobileOnly" style="font-weight:normal;">Remove mobile devices only (iOS/Android)</label> | |
</div> | |
</div> | |
<div class="offboarding-action-group"> | |
<input type="checkbox" id="offboardAddNotes"><label for="offboardAddNotes">Add Offboarding Notes</label> | |
<div class="action-details" id="offboardNotesDetails"> | |
<textarea id="offboardNotesText" rows="3" placeholder="Enter notes to add (will be appended)..."></textarea> | |
</div> | |
</div> | |
<hr style="margin-top:20px; margin-bottom:15px;"/> | |
<button id="runOffboardingTasksBtn" title="Execute all selected offboarding tasks for the user. A backup will be created first.">Run Selected Offboarding Tasks</button> | |
</div> | |
<div class="modal-buttons" style="margin-top: 20px;"> | |
<button id="closeManageModalBtn" type="button">Close</button> | |
</div> | |
</div> | |
</div> | |
<div id="editUserDetailsModal" class="modal"> | |
<div class="modal-content" style="max-width: 800px;"> | |
<h2>Edit Details for: <span id="editUserModalUpn" style="font-weight: normal;"></span></h2> | |
<div id="editUserModalStatusContainer" class="modal-status-container" style="display: none;"> | |
<span id="editUserModalStatusMessage"></span> | |
</div> | |
<div class="edit-user-form-container"> | |
<table class="edit-user-table"> | |
<thead> | |
<tr> | |
<th>Field</th> | |
<th>New Value</th> | |
<th>Current Value</th> | |
</tr> | |
</thead> | |
<tbody> | |
<tr> | |
<td class="field-label">UPN</td> | |
<td class="field-input"> | |
<div style="position: relative;"> <div style="display: flex;"> | |
<input type="text" id="edit_UserPrincipalName_User"> <span style="padding: 6px;">@</span> | |
<select id="edit_UserPrincipalName_Domain" data-original-domain=""></select> | |
</div> | |
<div id="edit-username-custom-dropdown" class="custom-dropdown-content"></div> | |
</div> | |
</td> | |
<td class="field-current-value" id="current_UserPrincipalName"></td> | |
</tr> | |
<tr><td colspan="3" class="section-header">User Details</td></tr> | |
<tr> | |
<td class="field-label">Display Name</td> | |
<td class="field-input"><input type="text" id="edit_DisplayName"></td> | |
<td class="field-current-value" id="current_DisplayName"></td> | |
</tr> | |
<tr> | |
<td class="field-label">First Name</td> | |
<td class="field-input"><input type="text" id="edit_GivenName"></td> | |
<td class="field-current-value" id="current_GivenName"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Last Name</td> | |
<td class="field-input"><input type="text" id="edit_Surname"></td> | |
<td class="field-current-value" id="current_Surname"></td> | |
</tr> | |
<tr><td colspan="3" class="section-header">Organizational Details</td></tr> | |
<tr> | |
<td class="field-label">Title</td> | |
<td class="field-input"><input type="text" id="edit_JobTitle"></td> | |
<td class="field-current-value" id="current_JobTitle"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Department</td> | |
<td class="field-input"><input type="text" id="edit_Department"></td> | |
<td class="field-current-value" id="current_Department"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Company</td> | |
<td class="field-input"><input type="text" id="edit_CompanyName"></td> | |
<td class="field-current-value" id="current_CompanyName"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Manager</td> | |
<td class="field-input"> | |
<div style="display: flex; gap: 5px;"> | |
<input type="text" id="edit_Manager" readonly placeholder="Select a manager..."> | |
<button type="button" id="editPickManagerBtn">Pick</button> | |
<button type="button" id="editClearManagerBtn" title="Clear Manager">X</button> | |
</div> | |
</td> | |
<td class="field-current-value" id="current_ManagerDisplayName"></td> | |
</tr> | |
<tr><td colspan="3" class="section-header">Location & Contact</td></tr> | |
<tr> | |
<td class="field-label">Office Location</td> | |
<td class="field-input"><input type="text" id="edit_OfficeLocation"></td> | |
<td class="field-current-value" id="current_PhysicalDeliveryOfficeName"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Street Address</td> | |
<td class="field-input"><input type="text" id="edit_StreetAddress"></td> | |
<td class="field-current-value" id="current_StreetAddress"></td> | |
</tr> | |
<tr> | |
<td class="field-label">City</td> | |
<td class="field-input"><input type="text" id="edit_City"></td> | |
<td class="field-current-value" id="current_City"></td> | |
</tr> | |
<tr> | |
<td class="field-label">State/Province</td> | |
<td class="field-input"><input type="text" id="edit_State"></td> | |
<td class="field-current-value" id="current_State"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Postal/Zip Code</td> | |
<td class="field-input"><input type="text" id="edit_PostalCode"></td> | |
<td class="field-current-value" id="current_PostalCode"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Country</td> | |
<td class="field-input"><input type="text" id="edit_Country"></td> | |
<td class="field-current-value" id="current_Country"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Usage Location</td> | |
<td class="field-input"> | |
<select id="edit_UsageLocation" style="width: 100%;"> | |
<option value="">-- Not Set --</option> | |
<option value="AU">Australia</option> | |
<option value="CA">Canada</option> | |
<option value="DE">Germany</option> | |
<option value="FR">France</option> | |
<option value="GB">United Kingdom</option> | |
<option value="IN">India</option> | |
<option value="IE">Ireland</option> | |
<option value="JP">Japan</option> | |
<option value="NL">Netherlands</option> | |
<option value="NZ">New Zealand</option> | |
<option value="PH">Philippines</option> | |
<option value="SG">Singapore</option> | |
<option value="US">United States</option> | |
</select> | |
</td> | |
<td class="field-current-value" id="current_UsageLocation"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Mobile Phone</td> | |
<td class="field-input"><input type="text" id="edit_MobilePhone"></td> | |
<td class="field-current-value" id="current_MobilePhone"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Business Phone</td> | |
<td class="field-input"><input type="text" id="edit_BusinessPhones"></td> | |
<td class="field-current-value" id="current_BusinessPhones"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Fax Number</td> | |
<td class="field-input"><input type="text" id="edit_FaxNumber"></td> | |
<td class="field-current-value" id="current_FaxNumber"></td> | |
</tr> | |
<tr><td colspan="3" class="section-header">Notes</td></tr> | |
<tr> | |
<td class="field-label">Notes / Info</td> | |
<td class="field-input"><textarea id="edit_Notes" rows="4"></textarea></td> | |
<td class="field-current-value"><div id="current_Notes" class="notes-display"></div></td> | |
</tr> | |
</tbody> | |
</table> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveUserDetailsBtn" disabled>Save Changes</button> | |
<button id="closeEditUserModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageGroupsModal" class="modal"> | |
<div class="modal-content" style="max-width: 1000px;"> | |
<h2>Manage Group Memberships for: <span id="manageGroupsUserUpn" style="font-weight: normal;"></span></h2> | |
<div id="manageGroupsStatusContainer" class="modal-status-container" style="display: none;"> | |
<span id="manageGroupsStatusMessage"></span> | |
</div> | |
<div class="group-management-container-new"> | |
<div class="group-panel-new listbox-only-panel"> | |
<h4>Current Memberships (<span id="currentGroupsCount">0</span>)</h4> | |
<div class="listbox-aligner"></div> | |
<select id="currentGroupsList" multiple class="groups-modal-listbox"></select> | |
<div class="button-group"> | |
<button type="button" id="stageSelectedGroupsForRemovalBtn" class="small-button">Queue Selected For Removal >></button> | |
<button type="button" id="stageAllGroupsForRemovalBtn" class="small-button">Queue ALL For Removal >></button> | |
</div> | |
</div> | |
<div class="group-panel-new"> | |
<h4>Copy Groups From User (<span id="copyUserGroupsCount">0</span>)</h4> | |
<div class="inline-picker" style="margin-bottom: 5px;"> | |
<input type="text" id="copyGroupsFromUser" readonly placeholder="Pick a user..."> | |
<button type="button" id="pickCopyUserBtn">Pick User</button> | |
<button type="button" id="loadCopyUserGroupsBtn">Load</button> | |
</div> | |
<select id="loadedCopyUserGroupsList" multiple class="groups-modal-listbox"></select> | |
<div class="button-group"> | |
<button type="button" id="stageSelectedCopiedGroupsBtn" class="small-button"> Queue Selected For Addition >></button> | |
<button type="button" id="stageAllCopiedGroupsBtn" class="small-button"> Queue ALL For Addition >></button> | |
</div> | |
</div> | |
<div class="group-panel-new"> | |
<div class="sub-panel-new"> | |
<h5>Groups to Add (<span id="addGroupsCount">0</span>)</h5> | |
<div class="inline-picker" style="margin-bottom: 5px;"> | |
<input type="text" id="addGroupSearchInput" placeholder="Search for a group..."> | |
<button type="button" id="searchAndAddGroupBtn">Search</button> | |
</div> | |
<select id="addGroupsList" multiple class="groups-modal-listbox short-groups-listbox"></select> | |
<div class="button-group"> | |
<button type="button" id="unstageSelectedAddBtn" class="small-button"><< Remove from Queue</button> | |
<button type="button" id="unstageAllAddBtn" class="small-button">Clear Queue</button> | |
</div> | |
</div> | |
<div class="sub-panel-new"> | |
<h5>Groups to Remove (<span id="removeGroupsCount">0</span>)</h5> | |
<select id="removeGroupsList" multiple class="groups-modal-listbox short-groups-listbox"></select> | |
<div class="button-group"> | |
<button type="button" id="unstageSelectedRemoveBtn" class="small-button"><< Remove from Queue</button> | |
<button type="button" id="unstageAllRemoveBtn" class="small-button">Clear Queue</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveGroupChangesBtn">Save Changes</button> | |
<button id="closeManageGroupsModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageLicensesModal" class="modal"> | |
<div class="modal-content" style="max-width: 950px;"> | |
<h2>Manage Licenses for: <span id="manageLicensesUserUpn" style="font-weight: normal;"></span></h2> | |
<div id="manageLicensesStatusContainer" class="modal-status-container"> | |
<span id="manageLicensesStatusMessage"></span> | |
</div> | |
<div class="license-management-container"> | |
<div class="license-panel"> | |
<h4>Current Licenses (<span id="currentUserLicenseCount">0</span>)</h4> | |
<select id="currentUserLicensesList" multiple class="license-listbox"></select> | |
<button type="button" id="stageLicenseForRemovalBtn" disabled>Queue Selected For Removal >></button> | |
</div> | |
<div class="license-panel"> | |
<h4>Available Tenant Licenses (<span id="availableTenantLicenseCount">0</span>)</h4> | |
<input type="text" id="filterAvailableLicenses" placeholder="Filter available licenses..." style="width: 100%; margin-bottom: 5px; padding: 4px; box-sizing: border-box;"> | |
<select id="availableTenantLicensesList" multiple class="license-listbox"></select> | |
<button type="button" id="stageLicenseForAdditionBtn" disabled>Queue Selected For Addition >></button> | |
</div> | |
<div class="license-panel"> | |
<h4>Changes to Apply</h4> | |
<div class="sub-panel"> | |
<h5>Licenses to Add (<span id="addLicenseCount">0</span>)</h5> | |
<select id="addLicensesList" multiple class="license-listbox"></select> | |
<button type="button" id="unstageAddLicenseBtn"><< Remove from Queue</button> | |
</div> | |
<div class="sub-panel"> | |
<h5>Licenses to Remove (<span id="removeLicenseCount">0</span>)</h5> | |
<select id="removeLicensesList" multiple class="license-listbox"></select> | |
<button type="button" id="unstageRemoveLicenseBtn"><< Remove from Queue</button> | |
</div> | |
</div> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveLicenseChangesBtn" disabled>Save Changes</button> | |
<button id="closeManageLicensesModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageAliasesModal" class="modal"> | |
<div class="modal-content" style="max-width: 750px;"> | |
<h2>Manage Email Aliases for: <span id="manageAliasesUserUpn" style="font-weight: normal;"></span></h2> | |
<div id="manageAliasesStatusContainer" class="modal-status-container"> | |
<span id="manageAliasesStatusMessage"></span> | |
</div> | |
<div class="aliases-management-container"> | |
<!-- Current Aliases --> | |
<div class="aliases-panel"> | |
<h4>Current Aliases (<span id="currentAliasesCount">0</span>)</h4> | |
<select id="currentAliasesList" size="10" class="aliases-listbox"></select> | |
<div class="alias-actions-buttons"> | |
<button type="button" id="removeSelectedAliasBtn" disabled>Remove Selected</button> | |
<button type="button" id="setAsPrimaryBtn" disabled>Set Selected as Primary</button> | |
</div> | |
</div> | |
<!-- Add New Alias --> | |
<div class="aliases-panel"> | |
<h4>Add New Alias</h4> | |
<div class="inline-picker" style="margin-bottom: 10px;"> | |
<input type="text" id="newAliasInput" list="alias-suggestions" placeholder="e.g., new.alias"> | |
<datalist id="alias-suggestions"></datalist> | |
<span style="padding: 0 5px;">@</span> | |
<select id="aliasDomainDropdown"></select> | |
</div> | |
<button type="button" id="addNewAliasBtn" disabled>Add to List</button> | |
</div> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveAliasChangesBtn" disabled>Save Changes</button> | |
<button id="closeManageAliasesModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageAutoreplyModal" class="modal"> | |
<div class="modal-content" style="max-width: 650px;"> | |
<h2>Manage Autoreply (OOF) for: <span id="manageAutoreplyUserUpn" style="font-weight: normal;"></span></h2> | |
<div id="manageAutoreplyStatusContainer" class="modal-status-container"> | |
<span id="manageAutoreplyStatusMessage"></span> | |
</div> | |
<div class="autoreply-form-container"> | |
<div class="form-group"> | |
<label>Autoreply State:</label> | |
<div class="radio-group"> | |
<input type="radio" id="oofStateDisabled" name="oofState" value="Disabled" checked> <label for="oofStateDisabled">Disabled</label> | |
<input type="radio" id="oofStateEnabled" name="oofState" value="Enabled"> <label for="oofStateEnabled">Enabled</label> | |
<input type="radio" id="oofStateScheduled" name="oofState" value="Scheduled"> <label for="oofStateScheduled">Scheduled</label> | |
</div> | |
</div> | |
<div id="oofScheduledSettings" class="form-group-nested" style="display: none;"> | |
<div class="form-group"> | |
<label for="oofStartTime">Start Time:</label> | |
<input type="datetime-local" id="oofStartTime"> | |
</div> | |
<div class="form-group"> | |
<label for="oofEndTime">End Time:</label> | |
<input type="datetime-local" id="oofEndTime"> | |
</div> | |
</div> | |
<div class="form-group"> | |
<label for="oofExternalAudience">External Audience:</label> | |
<select id="oofExternalAudience"> | |
<option value="None">None</option> | |
<option value="Known">Known Senders Only</option> | |
<option value="All">All External Senders</option> | |
</select> | |
</div> | |
<div class="form-group"> | |
<label for="oofInternalReply">Internal Reply Message:</label> | |
<textarea id="oofInternalReply" rows="12" placeholder="Message for senders inside your organization..."></textarea> | |
</div> | |
<div class="form-group"> | |
<label for="oofExternalReply">External Reply Message:</label> | |
<textarea id="oofExternalReply" rows="12" placeholder="Message for senders outside your organization..."></textarea> | |
</div> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveAutoreplySettingsBtn" disabled>Save Changes</button> | |
<button id="closeAutoreplyModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageDelegationForwardingModal" class="modal"> | |
<div class="modal-content" style="max-width: 900px;"> | |
<h2>Mailbox Access & Forwarding for: <span id="delFwdUserUpn" style="font-weight: normal;"></span></h2> | |
<div id="delFwdStatusContainer" class="modal-status-container"> | |
<span id="delFwdStatusMessage"></span> | |
</div> | |
<div class="tab-buttons"> | |
<button type="button" id="delegationTabBtn" class="tab-button active-tab">Delegations</button> | |
<button type="button" id="forwardingTabBtn" class="tab-button">Forwarding</button> | |
</div> | |
<div id="delegationsContentDiv" class="tab-content active-content"> | |
<h4>Manage Delegations</h4> | |
<div class="delegation-sections-container"> | |
<div class="delegation-current-section"> | |
<h5>Current Delegations:</h5> | |
<label>Full Access:</label> | |
<select id="currentFullAccessList" multiple class="delegation-listbox"></select> | |
<label>Send As:</label> | |
<select id="currentSendAsList" multiple class="delegation-listbox"></select> | |
<label>Send on Behalf:</label> | |
<select id="currentSendOnBehalfList" multiple class="delegation-listbox"></select> | |
<button type="button" id="stageDelegationForRemovalBtn" class="small-button" disabled>Queue Selected For Removal >></button> | |
</div> | |
<div class="delegation-add-section"> | |
<h5>Add New / Modify Delegation:</h5> | |
<div class="inline-picker"> | |
<input type="text" id="delegateUserInput" readonly placeholder="Pick a user..."> | |
<button type="button" id="pickDelegateUserBtn">Pick User</button> | |
</div> | |
<div class="permission-checkboxes"> | |
<input type="checkbox" id="chkFullAccess"> <label for="chkFullAccess">Full Access</label> | |
<div id="automapOptionDiv" style="padding-left: 20px; display: none;"> | |
<input type="checkbox" id="chkAutoMapping" checked> <label for="chkAutoMapping">Enable Automapping</label> | |
</div> | |
<input type="checkbox" id="chkSendAs"> <label for="chkSendAs">Send As</label> | |
<input type="checkbox" id="chkSendOnBehalf"> <label for="chkSendOnBehalf">Send on Behalf</label> | |
</div> | |
<button type="button" id="stageDelegationChangeBtn" class="small-button" disabled>Queue This Delegate's Permissions >></button> | |
</div> | |
<div class="delegation-staged-section"> | |
<h5>Pending Changes:</h5> | |
<select id="stagedDelegationChangesList" multiple class="delegation-listbox" title="Double-click to unstage"></select> | |
<button type="button" id="unstageSelectedDelegationBtn" class="small-button"><< Remove from Queue</button> | |
</div> | |
</div> | |
</div> | |
<div id="forwardingContentDiv" class="tab-content"> | |
<h4>Manage Email Forwarding</h4> | |
<div class="form-group"> | |
<label>Current Forwarding Address:</label> | |
<input type="text" id="currentForwardingAddress" readonly placeholder="Not set"> | |
</div> | |
<div class="form-group"> | |
<input type="checkbox" id="currentDeliverToMailboxAndForward" disabled> | |
<label for="currentDeliverToMailboxAndForward">Deliver to mailbox and forward (current)</label> | |
</div> | |
<hr> | |
<h5>New Forwarding Settings:</h5> | |
<div class="form-group"> | |
<label>Forwarding State:</label> | |
<input type="radio" id="fwdStateDisable" name="fwdState" value="Disable" checked> <label for="fwdStateDisable">Disable Forwarding</label> | |
<input type="radio" id="fwdStateEnable" name="fwdState" value="Enable"> <label for="fwdStateEnable">Enable Forwarding</label> | |
</div> | |
<div id="fwdSettingsDiv" class="form-group-nested"> | |
<div class="form-group inline-picker"> | |
<label for="forwardToUserInput">Forward to:</label> | |
<input type="text" id="forwardToUserInput" readonly placeholder="Pick user/mailbox..."> | |
<button type="button" id="pickForwardToUserBtn">Pick User</button> | |
</div> | |
<div class="form-group"> | |
<input type="checkbox" id="chkDeliverToMailboxAndForward" checked> | |
<label for="chkDeliverToMailboxAndForward">Deliver to mailbox and forward (new setting)</label> | |
</div> | |
</div> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveDelFwdChangesBtn" disabled>Save Changes</button> | |
<button id="closeDelFwdModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="manageTeamsPhoneModal" class="modal"> | |
<div class="modal-content" style="max-width: 850px;"> | |
<h2>Manage Teams Phone Number for: <span id="teamsPhoneUserUpn" style="font-weight: normal;"></span></h2> | |
<div id="teamsPhoneStatusContainer" class="modal-status-container"> | |
<span id="teamsPhoneStatusMessage"></span> | |
</div> | |
<div class="teams-phone-sections-container"> | |
<div class="teams-phone-panel"> | |
<h4>Current Teams Telephony Settings</h4> | |
<div class="form-grid"> | |
<div><label>Current Phone Number (LineURI):</label><input type="text" id="currentTeamsLineUri" readonly></div> | |
<div><label>Number Type:</label><input type="text" id="currentTeamsNumberType" readonly></div> | |
<div><label>Enterprise Voice Enabled:</label><input type="text" id="currentTeamsEVEnabled" readonly></div> | |
<div><label>Teams Calling Policy:</label><input type="text" id="currentTeamsCallingPolicy" readonly></div> | |
<div><label>Teams Upgrade Policy:</label><input type="text" id="currentTeamsUpgradePolicy" readonly></div> | |
<div><label>Tenant Dial Plan:</label><input type="text" id="currentTeamsTenantDialPlan" readonly></div> | |
<div><label>Online Voice Routing Policy:</label><input type="text" id="currentTeamsVoiceRoutingPolicy" readonly></div> | |
</div> | |
</div> | |
<div class="teams-phone-panel"> | |
<h4>Assign / Modify Phone Number</h4> | |
<div class="form-group"> | |
<label for="assignTeamsPhoneNumberInput">Phone Number (e.g., tel:+1234567890):</label> | |
<div style="display:flex; gap:5px;"> | |
<input type="text" id="assignTeamsPhoneNumberInput" placeholder="Enter full number or select below"> | |
<button type="button" id="removeTeamsNumberBtn" class="small-button">Clear/Unassign</button> | |
</div> | |
</div> | |
<div class="form-group"> | |
<label for="teamsEmergencyLocationSelect">Emergency Location (Optional - for some number types):</label> | |
<select id="teamsEmergencyLocationSelect"> | |
<option value="">None</option> | |
</select> | |
</div> | |
<div class="form-group"> | |
<label for="availableTeamsNumbersList">Available Numbers (from Tenant - may be limited):</label> | |
<div style="display:flex; gap:5px;"> | |
<select id="availableTeamsNumbersList" size="3" class="teams-phone-listbox"></select> | |
<button type="button" id="findAvailableTeamsNumbersBtn" class="small-button" style="align-self: flex-start;">Find Numbers</button> | |
</div> | |
<button type="button" id="useSelectedTeamsNumberBtn" class="small-button" style="margin-top:5px;">Use Selected Number</button> | |
</div> | |
</div> | |
<div class="teams-phone-panel"> | |
<h4>Modify Policies & Settings</h4> | |
<div class="form-grid"> | |
<div class="form-group"> | |
<input type="checkbox" id="chkTeamsEVEnabled" checked> <label for="chkTeamsEVEnabled">Enterprise Voice Enabled</label> | |
</div> | |
<div class="form-group"> | |
<label for="selectTeamsCallingPolicy">Teams Calling Policy:</label> | |
<select id="selectTeamsCallingPolicy"><option value="">(No Change)</option></select> | |
</div> | |
<div class="form-group"> | |
<label for="selectTeamsUpgradePolicy">Teams Upgrade Policy:</label> | |
<select id="selectTeamsUpgradePolicy"><option value="">(No Change)</option></select> | |
</div> | |
<div class="form-group"> | |
<label for="selectTeamsTenantDialPlan">Tenant Dial Plan:</label> | |
<select id="selectTeamsTenantDialPlan"><option value="">(No Change)</option></select> | |
</div> | |
<div class="form-group"> | |
<label for="selectTeamsVoiceRoutingPolicy">Online Voice Routing Policy:</label> | |
<select id="selectTeamsVoiceRoutingPolicy"><option value="">(No Change)</option></select> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="modal-buttons"> | |
<button id="saveTeamsPhoneChangesBtn" disabled>Save Changes</button> | |
<button id="closeTeamsPhoneModalBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="pickLicenseSubModal" class="modal" style="z-index: 1050;"> | |
<div class="modal-content" style="max-width: 600px;"> | |
<h2>Select Licenses for New User</h2> | |
<div id="pickLicenseStatusContainer" class="modal-status-container" style="margin-bottom:10px;"> | |
<span id="pickLicenseStatusMessage"></span> | |
</div> | |
<input type="text" id="filterPickableLicenses" placeholder="Filter available licenses..." style="width: 100%; margin-bottom: 10px; padding: 6px; box-sizing: border-box;"> | |
<label>Available Tenant Licenses:</label> | |
<select id="pickableLicensesList" multiple style="width: 100%; height: 250px; margin-bottom: 15px;"></select> | |
<div class="modal-buttons"> | |
<button id="confirmLicenseSelectionBtn">OK</button> | |
<button id="cancelLicenseSelectionBtn" type="button">Cancel</button> | |
</div> | |
</div> | |
</div> | |
<div id="portalsModal" class="modal"> | |
<div class="modal-content" style="max-width: 450px;"> | |
<h2>Admin Portals</h2> | |
<div id="portalListContainer" class="portal-list-container"> | |
</div> | |
<div class="modal-buttons" style="margin-top: 20px;"> | |
<button id="closePortalsModalBtn" type="button">Close</button> | |
</div> | |
</div> | |
</div> | |
<div id="runPsCommandModal" class="modal"> | |
<div class="modal-content" style="max-width: 700px;"> | |
<h2>Run PowerShell Command</h2> | |
<div id="runPsCommandStatusContainer" class="modal-status-container" style="margin-bottom:10px;"> | |
<span id="runPsCommandStatusMessage"></span> | |
</div> | |
<p style="color: red; font-weight: bold;">⚠️ Use with caution! Commands are executed directly in the backend PowerShell console.</p> | |
<div class="form-group"> | |
<label for="psCommandInput">Enter PowerShell Command(s):</label> | |
<textarea id="psCommandInput" rows="8" placeholder="Enter PowerShell command(s) here..."></textarea> | |
</div> | |
<div class="modal-buttons"> | |
<button id="executePsCommandBtn">Execute Command</button> | |
<button id="clearPsInputBtn" type="button">Clear Input</button> | |
<button id="closeRunPsModalBtn" type="button">Close</button> | |
</div> | |
</div> | |
</div> | |
<div id="groupDetailsModal" class="modal"> | |
<div class="modal-content" style="max-width: 600px;"> | |
<h2>Group Details</h2> | |
<div id="groupDetailsStatusContainer" class="modal-status-container" style="display: none;"> | |
<span id="groupDetailsStatusMessage"></span> | |
</div> | |
<div class="edit-user-form-container" style="max-height: 65vh;"> | |
<table class="edit-user-table"> | |
<tbody> | |
<tr><td colspan="2" class="section-header">Group Information</td></tr> | |
<tr> | |
<td class="field-label">Display Name</td> | |
<td class="field-current-value" id="groupDetail_DisplayName"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Email / UPN</td> | |
<td class="field-current-value" id="groupDetail_Mail"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Group Type(s)</td> | |
<td class="field-current-value" id="groupDetail_GroupTypes"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Privacy</td> | |
<td class="field-current-value" id="groupDetail_Visibility"></td> | |
</tr> | |
<tr> | |
<td class="field-label">Owner(s)</td> | |
<td class="field-current-value" id="groupDetail_Owners"></td> | |
</tr> | |
<tr id="groupDetail_DynamicRuleRow" style="display: none;"> | |
<td class="field-label">Dynamic Membership Rule</td> | |
<td class="field-current-value" style="font-size: 0.8em; white-space: pre-wrap;" id="groupDetail_MembershipRule"></td> | |
</tr> | |
<tr> | |
<td colspan="2" class="section-header" style="display: flex; justify-content: space-between; align-items: center;"> | |
<span>Members (<span id="groupDetail_MemberCount">0</span>)</span> | |
<button id="copyGroupMembersBtn" type="button" style="font-size: 10px; padding: 2px 5px;" title="Copy member list to clipboard">Copy Members</button> | |
</td> | |
</tr> | |
<tr> | |
<td colspan="2"> | |
<div id="groupDetail_MemberList" class="scrollable-list-medium" style="max-height: 300px; font-size: 1em;"> | |
</div> | |
</td> | |
</tr> | |
</tbody> | |
</table> | |
</div> | |
<div class="modal-buttons"> | |
<button id="closeGroupDetailsModalBtn" type="button">Close</button> | |
</div> | |
</div> | |
</div> | |
<div id="searchModal" class="modal"> | |
<div class="modal-content" style="max-width: 800px;"> | |
<h2>User Search</h2> | |
<a href="javascript:void(0)" id="searchToggleAdvancedBtn" style="font-size: 11px; text-decoration: none;">Advanced Search ▼</a> | |
<div id="advancedSearchPanel" style="display: none; background-color: #f0f0f0; border: 1px solid #ccc; padding: 10px; margin-top: 10px; border-radius: 4px;"> | |
<div style="display: flex; gap: 10px; align-items: center;"> | |
<select id="searchFieldSelect" style="padding: 6px;"> | |
<option value="Default" selected>All (Name, UPN, Email)</option> | |
<option value="jobTitle">Title</option> | |
<option value="department">Department</option> | |
</select> | |
<input type="text" id="advancedSearchInput" style="flex-grow: 1; padding: 6px;" placeholder="Enter search text..."> | |
<button id="advancedSearchBtn" type="button">Search</button> | |
<button id="clearSearchBtn" type="button">Clear</button> | |
</div> | |
</div> | |
<div id="searchSpinner" style="display: none; text-align: center; padding: 20px;"> | |
<span style="font-size: 1.2em;">Searching...</span> | |
</div> | |
<div id="searchResultsContainer" style="display: none;"> | |
<table class="search-results-table"> | |
<thead> | |
<tr> | |
<th class="sortable-header" data-sortkey="displayName" style="width: 20%;">Display Name</th> | |
<th class="sortable-header" data-sortkey="upn" style="width: 23%;">UPN / Email</th> | |
<th class="sortable-header" data-sortkey="title" style="width: 23%;">Title</th> | |
<th class="sortable-header" data-sortkey="department" style="width: 20%;">Department</th> | |
<th class="sortable-header" data-sortkey="source" style="width: 7%;">Source</th> | |
<th class="sortable-header" data-sortkey="enabled" style="width: 7%;">Active</th> | |
</tr> | |
</thead> | |
<tbody id="searchResultsBody"> | |
</tbody> | |
</table> | |
</div> | |
<div class="search-pagination-controls" style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px;"> | |
<button id="searchPrevBtn" type="button" disabled>Previous</button> | |
<span id="searchResultsInfo"></span> | |
<button id="searchNextBtn" type="button" disabled>Next</button> | |
</div> | |
<div class="modal-buttons" style="margin-top: 20px;"> | |
<button id="closeSearchModalBtn" type="button">Close</button> | |
</div> | |
</div> | |
</div> | |
</body> | |
</html> | |
"@ | |
#====================================================================================================================================================================================================================== | |
# --- PowerShell Backend Code (Starts here and goes to the end of the file) --- | |
$listener = [System.Net.HttpListener]::new() | |
$listener.Prefixes.Add("http://localhost:8080/") | |
try { | |
$listener.Start() | |
} | |
catch { | |
Write-Error "Failed to start HttpListener. Is port 8080 already in use or do you lack permissions? Error: $($_.Exception.Message)" | |
exit 1 | |
} | |
Write-Output "Listening at http://localhost:8080 for UserDetailsDashboard" | |
Write-Output "IMPORTANT: Authentication prompts will appear in THIS PowerShell console window." | |
# Launch browser based on settings | |
try { | |
$browserToLaunch = $Global:ScriptSettings.selectedBrowser | |
$urlToOpen = "http://localhost:8080" | |
$windowSize = "--window-size=1460,870" # Default size | |
$windowPosition = "--window-position=100,100" # Default position | |
$appArg = "--app=$urlToOpen" # Launch as an "app" window if supported | |
$tempProfileDirBase = "$env:TEMP" # Use TEMP directory for transient profiles | |
$arguments = @() | |
switch ($browserToLaunch) { | |
"chrome" { | |
$exe = "chrome.exe" | |
$arguments = "--incognito", "--user-data-dir=$tempProfileDirBase\chrome-temp-profile-dashboard", $appArg, $windowSize, $windowPosition | |
} | |
"msedge" { | |
$exe = "msedge.exe" | |
$arguments = "--inprivate", "--user-data-dir=$tempProfileDirBase\edge-temp-profile-dashboard", $appArg, $windowSize, $windowPosition | |
} | |
"brave" { | |
$exe = "brave.exe" | |
$arguments = "--incognito", "--user-data-dir=$tempProfileDirBase\brave-temp-profile-dashboard", $appArg, $windowSize, $windowPosition | |
} | |
"opera" { | |
$exe = "opera.exe" | |
$arguments = "--private", "--user-data-dir=$tempProfileDirBase\opera-temp-profile-dashboard", $appArg, $windowSize, $windowPosition | |
} | |
"firefox" { | |
$exe = "firefox.exe" | |
# Firefox handles temporary profiles differently; -profile requires an existing directory. | |
$profilePath = Join-Path -Path $tempProfileDirBase -ChildPath "firefox-temp-profile-dashboard" | |
if (-not (Test-Path $profilePath)) { New-Item -ItemType Directory -Path $profilePath -Force | Out-Null } | |
# Firefox does not have a direct "--app" equivalent like Chrome. -url is the best option. | |
# Window size/position for Firefox is harder to control via CLI in a cross-platform way for -private-window. | |
$arguments = "-private-window", "-profile", "`"$profilePath`"", "-url", $urlToOpen | |
} | |
default { | |
Write-Warning "Unsupported browser '$browserToLaunch' in settings. Defaulting to Chrome." | |
$exe = "chrome.exe" | |
$arguments = "--incognito", "--user-data-dir=$tempProfileDirBase\chrome-temp-profile-dashboard", $appArg, $windowSize, $windowPosition | |
} | |
} | |
#Write-Output "Attempting to launch $exe with arguments: $($arguments -join ' ')" | |
Start-Process $exe -ArgumentList $arguments -ErrorAction Stop | |
} | |
catch { | |
Write-Warning "Could not automatically open selected browser. Please manually open http://localhost:8080 in your browser. Error: $($_.Exception.Message)" | |
} | |
# === Begin main backend loop: continuously handle incoming HTTP requests while the listener is active ================================================================================================================ | |
while ($Global:IsListenerRunning -and $listener.IsListening) { | |
$context = $listener.GetContext() | |
#Write-Output ("DEBUG PS: Listener received a request. Path: {0}" -f $context.Request.Url.AbsolutePath.ToLower()) | |
$path = $context.Request.Url.AbsolutePath.ToLower() | |
$query = $context.Request.Url.Query | |
$body = "" | |
$context.Response.ContentType = "text/plain" # Default content type | |
try { | |
switch ($path) { | |
"/" { | |
$body = $html | |
$context.Response.ContentType = "text/html" | |
} | |
"/shutdown" { | |
Write-Host "Shutdown signal received from UI. Stopping listener..." -ForegroundColor Yellow | |
$body = "OK, Shutting down." | |
$Global:IsListenerRunning = $false # Set the flag to false to exit the while loop | |
# No 'break' needed if it's the last statement in the case | |
} | |
"/getsettings" { | |
$context.Response.ContentType = "application/json" | |
try { | |
if ($null -eq $Global:ScriptSettings) { | |
Write-Warning "Global:ScriptSettings was null at /getsettings. Re-initializing from defaults." | |
$Global:ScriptSettings = $DefaultSettings | |
} | |
$body = $Global:ScriptSettings | ConvertTo-Json -Depth 5 | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Error getting settings: $($_.Exception.Message)" } | |
} | |
} | |
"/savesettings" { | |
if ($context.Request.HttpMethod -ne "POST") { | |
$context.Response.StatusCode = 405 | |
$body = ConvertTo-Json @{ error = "POST method required for /savesettings."} | |
} else { | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$jsonPayload = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | |
$receivedSettings = $jsonPayload | ConvertFrom-Json -ErrorAction Stop | |
# Update global settings variable, ensuring all keys from DefaultSettings are present | |
foreach($key in $DefaultSettings.Keys) { | |
if ($receivedSettings.PSObject.Properties.Name.Contains($key)) { | |
$Global:ScriptSettings.$key = $receivedSettings.$key | |
} elseif (-not $Global:ScriptSettings.PSObject.Properties.Name.Contains($key)) { | |
# If key is missing in received and also missing in current global (e.g. fresh start), add from default | |
$Global:ScriptSettings.$key = $DefaultSettings[$key] | |
} | |
} | |
# Ensure backupPath is an empty string if it comes as null from JSON | |
if ($null -ne $receivedSettings.backupPath) { | |
$Global:ScriptSettings.backupPath = $receivedSettings.backupPath.ToString() | |
} else { | |
$Global:ScriptSettings.backupPath = "" | |
} | |
# Update the effectiveViewOnlyMode based on new settings | |
$Global:effectiveViewOnlyMode = $true # PowerShell uses $true/$false for booleans | |
if ($Global:ScriptSettings.accessMode -eq "readwrite") { | |
$Global:effectiveViewOnlyMode = $false | |
} | |
# Save to file | |
$Global:ScriptSettings | ConvertTo-Json -Depth 5 | Set-Content -Path $SettingsFilePath -Encoding UTF8 -Force | |
$body = ConvertTo-Json @{ status = "Settings saved successfully." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Error saving settings: $($_.Exception.Message)" } | |
Write-Warning "Error saving settings: $($_.Exception.ToString())" | |
} | |
} | |
$context.Response.ContentType = "application/json" | |
} | |
"/connect/exchange" { | |
$context.Response.ContentType = "application/json" | |
try { | |
if ($Global:ConnectionState.Exchange) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } | |
$Global:ConnectionState.Exchange = $false | |
Import-Module ExchangeOnlineManagement -ErrorAction Stop | |
Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop *> $null | |
$Global:ConnectionState.Exchange = $true | |
$exchangeonlineAccount = (Get-ConnectionInformation).UserPrincipalName | |
Write-Host "Successfully connected to Exchange Online" -ForegroundColor Green | |
$body = ConvertTo-Json @{ status = "success"; message = "Successfully connected to Exchange Online." } | |
} catch { | |
$Global:ConnectionState.Exchange = $false | |
$errorMessage = "Exchange Connection Error: $($_.Exception.Message)" | |
Write-Warning $errorMessage | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ status = "error"; message = $errorMessage } | |
} | |
} | |
"/connect/graph" { | |
$context.Response.ContentType = "application/json" | |
try { | |
if ($Global:ConnectionState.Graph) { | |
Write-Host "Disconnecting existing Graph session..." | |
Disconnect-MgGraph -Confirm:$false -ErrorAction SilentlyContinue | |
} | |
$Global:ConnectionState.Graph = $false # Set to false until successful connection | |
$Global:Script:FetchedTenantName = "[Graph N/C]" | |
$graphScopes = @() | |
if ($Global:ScriptSettings.accessMode -eq "readwrite") { | |
Write-Host "Graph connection requested with ReadWrite scopes." -ForegroundColor Yellow | |
$graphScopes = @( | |
"User.ReadWrite.All", "Group.ReadWrite.All", "Directory.ReadWrite.All", | |
"Organization.Read.All", "UserAuthenticationMethod.ReadWrite.All", | |
"Device.ReadWrite.All", "MailboxSettings.ReadWrite","Domain.Read.All", | |
"Application.ReadWrite.All", "Policy.Read.All", "GroupMember.Read.All", | |
"Policy.ReadWrite.ApplicationConfiguration", "Directory.AccessAsUser.All", | |
"AuditLog.Read.All", "Reports.Read.All" | |
) | |
} else { # read-only mode | |
Write-Host "DEBUG PS: Graph connection requested with Read-Only scopes." | |
$graphScopes = @( | |
"User.Read.All", "Directory.Read.All", "Group.Read.All", "GroupMember.Read.All", | |
"Organization.Read.All", "UserAuthenticationMethod.Read.All", "Domain.Read.All", | |
"Device.Read.All", "MailboxSettings.Read", "Application.Read.All", | |
"Policy.Read.All", "AuditLog.Read.All", "Reports.Read.All" | |
) | |
} | |
# Define required modules and a key cmdlet from each for checking | |
$modulesToEnsure = @{ | |
"Microsoft.Graph.Authentication" = "Connect-MgGraph" | |
"Microsoft.Graph.Users" = "Get-MgUser" | |
"Microsoft.Graph.Users.Actions" = "Set-MgUserPassword" # For password reset | |
"Microsoft.Graph.Groups" = "Get-MgGroup" | |
"Microsoft.Graph.DirectoryObjects" = "Get-MgUserMemberOf" | |
"Microsoft.Graph.Identity.DirectoryManagement" = "Get-MgDomain" | |
"Microsoft.Graph.Identity.SignIns" = "Revoke-MgUserSignInSession" | |
#"Microsoft.Graph.DeviceManagement.ManagedDevices" = "Get-MgDevice" # Corrected from Get-MgUserRegisteredDevice for Remove-MgDevice | |
#"Microsoft.Graph.Beta.Users.Actions" = "Reset-MgBetaUserAuthenticationMethod" # If using specific MFA reset | |
} | |
Write-Host "Checking and importing required Graph modules..." | |
$totalModules = $modulesToEnsure.Count | |
$moduleCounter = 0 | |
foreach ($moduleName in $modulesToEnsure.Keys) { | |
$moduleCounter++ # Increment counter at the start of the loop | |
$cmdletToCheck = $modulesToEnsure[$moduleName] | |
if (-not (Get-Command $cmdletToCheck -Module $moduleName -ErrorAction SilentlyContinue)) { | |
#Write-Host "($moduleCounter/$totalModules) Module $moduleName not found. Attempting to import..." | |
try { | |
Import-Module $moduleName -ErrorAction Stop | |
Write-Host "($moduleCounter/$totalModules) Successfully imported $moduleName." | |
} catch { | |
throw "Failed to import $moduleName. If it's not installed, please install it: Install-Module $moduleName -Scope CurrentUser" | |
} | |
} else { | |
Write-Host "($moduleCounter/$totalModules) Module $moduleName is available." | |
} | |
} | |
Disconnect-MgGraph -ErrorAction SilentlyContinue *> $null | |
Remove-Item "$env:USERPROFILE\.mgcontext" -ErrorAction SilentlyContinue | |
Remove-Item "$env:LOCALAPPDATA\Microsoft\TokenCache" -Force -ErrorAction SilentlyContinue | |
Connect-MgGraph -Scopes $graphScopes -NoWelcome -ErrorAction Stop *> $null | |
$Global:ConnectionState.Graph = $true | |
# Fetch Tenant Name | |
$Global:Script:FetchedTenantName = "[Unknown Tenant]" # Reset to default before fetching | |
try { | |
# The Get-MgContext part is better for getting the logged-in user, | |
# Get-MgOrganization for the company display name. | |
$orgDetails = Get-MgOrganization -ErrorAction SilentlyContinue | Select-Object -First 1 | |
if ($orgDetails -and $orgDetails.DisplayName.Trim() -ne "") { | |
$Global:Script:FetchedTenantName = $orgDetails.DisplayName.Trim() | |
} | |
$graphAccount = (Get-MgContext).Account | |
Write-Host "Fetched Company Name: $($Global:Script:FetchedTenantName)" -ForegroundColor Cyan | |
Write-Host "Successfully connected to MS Graph as: $graphAccount" -ForegroundColor Green | |
} catch { | |
Write-Warning ("Could not fetch organization name: {0}" -f $_.Exception.Message) | |
} | |
# This is now the ONLY thing that will be sent in the response body | |
$body = ConvertTo-Json @{ | |
status = "success"; | |
message = "Successfully connected to Microsoft Graph."; | |
tenant = $Global:Script:FetchedTenantName | |
} | |
} catch { | |
$Global:ConnectionState.Graph = $false | |
$Global:Script:FetchedTenantName = "[Graph N/C]" | |
$errorMessage = "Graph Connection Error: $($_.Exception.Message)" | |
Write-Warning $errorMessage | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ status = "error"; message = $errorMessage } | |
} | |
} | |
"/connect/teams" { | |
$context.Response.ContentType = "application/json" | |
try { | |
if ($Global:ConnectionState.Teams) { Disconnect-MicrosoftTeams -Confirm:$false -ErrorAction SilentlyContinue } | |
$Global:ConnectionState.Teams = $false | |
Import-Module MicrosoftTeams -ErrorAction Stop | |
Connect-MicrosoftTeams -ErrorAction Stop *> $null | |
$Global:ConnectionState.Teams = $true | |
Write-Host "Successfully connected to Microsoft Teams" -ForegroundColor Green | |
$body = ConvertTo-Json @{ status = "success"; message = "Successfully connected to Microsoft Teams." } | |
} catch { | |
$Global:ConnectionState.Teams = $false | |
$errorMessage = "Teams Connection Error: $($_.Exception.Message)" | |
Write-Warning $errorMessage | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ status = "error"; message = $errorMessage } | |
} | |
} | |
"/disconnect" { | |
try { | |
if ($Global:ConnectionState.Exchange) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } | |
if (Get-Command Get-MgContext -ErrorAction SilentlyContinue) { | |
if (Get-MgContext -ErrorAction SilentlyContinue) { Disconnect-MgGraph -ErrorAction SilentlyContinue } | |
} | |
if ($Global:ConnectionState.Teams -and (Get-Command Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue)) { Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue } | |
$Global:ConnectionState.Exchange = $false | |
$Global:ConnectionState.Graph = $false | |
$Global:ConnectionState.Teams = $false | |
$Global:Script:FetchedTenantName = "Company Name" # Reset tenant name on disconnect | |
$body = "Disconnected all services attempted." | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Error during disconnection: $($_.Exception.Message)" } | |
$context.Response.ContentType = "application/json" | |
Write-Warning ("Disconnection Error: {0}" -f $_.Exception.ToString()) | |
} | |
} | |
"/status" { | |
$body = "" | |
$output = "Connection Status:" | |
$serviceOrder = @('Graph', 'Exchange', 'Teams') # Desired order | |
foreach ($serviceName in $serviceOrder) { | |
if ($Global:ConnectionState.ContainsKey($serviceName)) { | |
$status = if ($Global:ConnectionState[$serviceName]) { "Connected" } else { "Not Connected" } | |
$output += "`n${serviceName}: $status" | |
} | |
} | |
# Append any other services not in the defined order (for future proofing) | |
foreach ($serviceKey in $Global:ConnectionState.Keys) { | |
if ($serviceOrder -notcontains $serviceKey) { | |
$status = if ($Global:ConnectionState[$serviceKey]) { "Connected" } else { "Not Connected" } | |
$output += "`n${serviceKey}: $status" | |
} | |
} | |
$body = $output | |
} | |
"/simple-text-search" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ error = "Microsoft Graph connection required." } | |
} else { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($query) | |
$searchString = $parsedQuery["query"] | |
$matches = @() | |
if ($searchString -and $searchString.Trim() -ne "") { | |
try { | |
$escapedSearchString = $searchString.Replace("'", "''") | |
$allFoundUsers = @{} | |
$top = 10 # Keep the result set small for the prompt | |
# Using the same robust, sequential filter method from our advanced search. | |
$filters = @( | |
"startswith(displayName, '$escapedSearchString')", | |
"startswith(userPrincipalName, '$escapedSearchString')", | |
"startswith(mail, '$escapedSearchString')" | |
) | |
foreach ($filter in $filters) { | |
# We only need these two properties for the prompt display. | |
$userResults = Get-MgUser -Filter $filter -Property "Id,UserPrincipalName,DisplayName" -Top $top -ConsistencyLevel eventual -ErrorAction SilentlyContinue | |
if ($null -ne $userResults) { | |
foreach ($user in $userResults) { | |
if (-not $allFoundUsers.ContainsKey($user.Id)) { | |
$allFoundUsers[$user.Id] = $user | |
} | |
} | |
} | |
} | |
# Sort the combined, unique results and take the top 10 for the prompt. | |
$finalUserList = $allFoundUsers.Values | Sort-Object -Property DisplayName | Select-Object -First $top | |
if ($finalUserList) { | |
$matches = @($finalUserList | ForEach-Object { | |
@{ | |
upn = $_.UserPrincipalName | |
displayName = $_.DisplayName | |
} | |
}) | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$errorMessage = "Simple search failed: $($_.Exception.Message)" | |
$body = ConvertTo-Json @{ error = $errorMessage } | |
} | |
} | |
if (-not $body) { | |
$body = ConvertTo-Json $matches | |
} | |
} | |
} | |
"/advanced-user-search" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ error = "Microsoft Graph connection required." } | |
} else { | |
try { | |
function Invoke-SafeUserSearch { | |
param( [string]$FilterString ) | |
$selectProperties = "id,displayName,userPrincipalName,mail,onPremisesSyncEnabled,accountEnabled,jobTitle,department" | |
$results = Get-MgUser -Filter $FilterString -Property $selectProperties -ConsistencyLevel eventual -Top 100 -ErrorAction Stop | |
return $results | |
} | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($query) | |
$searchString = $parsedQuery["searchString"] | |
$searchField = $parsedQuery["searchField"] | |
if (-not $searchString) { throw "searchString parameter is required." } | |
$escapedSearchString = $searchString.Replace("'", "''") | |
$results = @() | |
if ($searchField -eq 'Default') { | |
$allFoundUsers = @{} | |
$filters = @( | |
"startswith(displayName, '$escapedSearchString')", | |
"startswith(userPrincipalName, '$escapedSearchString')", | |
"startswith(mail, '$escapedSearchString')" | |
) | |
foreach ($filter in $filters) { | |
$userResults = Invoke-SafeUserSearch -FilterString $filter | |
if ($null -ne $userResults) { | |
foreach ($user in $userResults) { $allFoundUsers[$user.Id] = $user } | |
} | |
} | |
$results = $allFoundUsers.Values | Sort-Object -Property DisplayName | |
} else { | |
$filterString = "startswith($searchField, '$escapedSearchString')" | |
$results = Invoke-SafeUserSearch -FilterString $filterString | |
} | |
$formattedResults = @() | |
if ($results) { | |
$formattedResults = @($results | ForEach-Object { | |
# --- START: IMPLEMENTING YOUR SOLUTION --- | |
# Forcing all keys to be camelCase to guarantee predictable JSON. | |
$searchFieldValue = switch ($searchField) { | |
'jobTitle' { $_.JobTitle } | |
'department' { $_.Department } | |
default { $_.UserPrincipalName } | |
} | |
@{ | |
displayName = $_.DisplayName | |
upn = $_.UserPrincipalName | |
title = $_.JobTitle | |
department = $_.Department | |
source = if ($_.OnPremisesSyncEnabled) { "Synced" } else { "Cloud" } | |
enabled = if ($_.AccountEnabled) { "Yes" } else { "No" } | |
searchFieldValue = $searchFieldValue | |
} | |
# --- END: IMPLEMENTING YOUR SOLUTION --- | |
}) | |
} | |
$body = ConvertTo-Json @{ results = $formattedResults } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Advanced search failed: $($_.Exception.Message)" } | |
} | |
} | |
} | |
"/getuserdetails" { | |
$context.Response.ContentType = "application/json" | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($query) | |
$userUpn = $parsedQuery["upn"] | |
$body = "" | |
$userDataToReturn = $null | |
try { | |
$userDataToReturn = Get-ComprehensiveUserDetails -UserUpn $userUpn | |
# Check if Get-ComprehensiveUserDetails itself returned an error object | |
if ($userDataToReturn -is [hashtable] -and $userDataToReturn.ContainsKey("Error")) { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json $userDataToReturn # Return the error object from the function | |
Write-Warning ("Get-ComprehensiveUserDetails returned an error object for {0}: {1}" -f $userUpn, ($userDataToReturn.Error)) | |
} elseif ($userDataToReturn -is [hashtable]) { | |
$body = ConvertTo-Json $userDataToReturn -Depth 10 | |
} else { | |
# This case should ideally not be reached if Get-ComprehensiveUserDetails always returns a hashtable | |
$context.Response.StatusCode = 500 | |
$unexpectedReturnType = if ($null -ne $userDataToReturn) { $userDataToReturn.GetType().FullName } else { "null" } | |
$body = ConvertTo-Json @{ error = ("Get-ComprehensiveUserDetails returned an unexpected type: {0}" -f $unexpectedReturnType) } | |
Write-Warning ("Get-ComprehensiveUserDetails returned an unexpected type for {0}: {1}" -f $userUpn, $unexpectedReturnType) | |
} | |
} catch { | |
# This catch block is for errors within the /getuserdetails endpoint itself, not from Get-ComprehensiveUserDetails | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = ("General error in /getuserdetails for {0}: {1}" -f $userUpn, $_.Exception.Message) } | |
Write-Warning ("Error in /getuserdetails for {0}: {1}" -f $userUpn, $_.Exception.ToString()) | |
} | |
} | |
"/backupuser" { | |
$context.Response.ContentType = "text/plain" | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($query) | |
$userUpn = $parsedQuery["upn"] | |
$body = "" | |
if (-not $userUpn) { | |
$context.Response.StatusCode = 400 | |
$body = "Error: UPN not provided for backup." | |
} else { | |
$fetchedData = $null | |
try { | |
$fetchedData = Get-ComprehensiveUserDetails -UserUpn $userUpn | |
if ($null -eq $fetchedData) { | |
throw "Get-ComprehensiveUserDetails returned null for backup." | |
} elseif (-not ($fetchedData -is [hashtable])) { | |
throw ("Get-ComprehensiveUserDetails returned unexpected type for backup: {0}" -f $fetchedData.GetType().FullName) | |
} elseif ($fetchedData.ContainsKey("Error")) { | |
throw ("Failed to retrieve comprehensive user details for backup of {0}: {1}" -f $userUpn, $fetchedData.Error) | |
} | |
# Create a clone for backup to avoid modifying the original fetched data if it's cached or reused | |
$backupData = [hashtable]$fetchedData.Clone() | |
$backupData["BackupTimestamp"] = (Get-Date).ToString("o") # ISO 8601 format | |
# Create a safe filename | |
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" | |
$upnParts = $userUpn.Split('@') | |
$wordBeforeDomainPart = $upnParts[0] | |
$domainPart = if ($upnParts.Length -gt 1) { $upnParts[1] } else { "NoDomain" } | |
# Sanitize parts for filename | |
$safeWordBeforeDomain = $wordBeforeDomainPart -replace "[^a-zA-Z0-9.-_]", "_" | |
$safeDomain = $domainPart -replace "[^a-zA-Z0-9.-_]", "_" | |
$fileNameBase = "${safeDomain}_${safeWordBeforeDomain}_${timestamp}_backup" | |
# Determine backup path | |
$customBackupPathFromSettings = $Global:ScriptSettings.backupPath | |
$finalBackupPathToUse = "" | |
$defaultDocumentsPath = [System.Environment]::GetFolderPath('MyDocuments') | |
$defaultBackupDirName = "UserDetailsDashboard_Backups" | |
$defaultBackupFullPath = Join-Path -Path $defaultDocumentsPath -ChildPath $defaultBackupDirName | |
if (-not [string]::IsNullOrWhiteSpace($customBackupPathFromSettings)) { | |
try { | |
if (-not ([System.IO.Path]::IsPathRooted($customBackupPathFromSettings))) { | |
Write-Warning "Custom backup path '$customBackupPathFromSettings' is not an absolute path. Using default." | |
$finalBackupPathToUse = $defaultBackupFullPath | |
} else { | |
$finalBackupPathToUse = $customBackupPathFromSettings | |
} | |
} catch { | |
Write-Warning "Error validating custom backup path '$customBackupPathFromSettings': $($_.Exception.Message). Using default." | |
$finalBackupPathToUse = $defaultBackupFullPath | |
} | |
} else { | |
Write-Output "DEBUG PS: Custom backup path is empty. Using default." | |
$finalBackupPathToUse = $defaultBackupFullPath | |
} | |
# Ensure directory exists | |
if (-not (Test-Path -Path $finalBackupPathToUse -PathType Container)) { | |
try { | |
Write-Output "DEBUG PS: Final backup path '$finalBackupPathToUse' does not exist. Attempting to create." | |
$null = New-Item -ItemType Directory -Path $finalBackupPathToUse -Force -ErrorAction Stop | |
Write-Output "DEBUG PS: Successfully created final backup directory: $finalBackupPathToUse" | |
} catch { | |
throw ("Could not create backup directory: {0}. Error: {1}" -f $finalBackupPathToUse, $_.Exception.Message) | |
} | |
} | |
$jsonFilePath = Join-Path -Path $finalBackupPathToUse -ChildPath "$fileNameBase.json" | |
$backupData | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonFilePath -Encoding UTF8 -Force | |
if (Test-Path $jsonFilePath) { | |
$body = "User details for $userUpn backed up to $jsonFilePath." | |
Write-Host "Backup created: $jsonFilePath" -ForegroundColor Yellow | |
} else { | |
throw "Failed to create backup file at $jsonFilePath." | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = "Error during backup process for ${userUpn}: $($_.Exception.Message)" | |
Write-Warning ("Backup Error for {0}: {1}" -f $userUpn, $_.Exception.ToString()) | |
} | |
} | |
} | |
"/runpscommand" { | |
$context.Response.ContentType = "application/json" | |
if ($context.Request.HttpMethod -ne "POST") { | |
$context.Response.StatusCode = 405 | |
$body = ConvertTo-Json @{ success = $false; output = "POST method required for /runpscommand."; confirmation = "Error" } | |
} elseif ($Global:ScriptSettings.accessMode -ne "readwrite") { | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ success = $false; output = "Script is in read-only mode. Command execution disabled."; confirmation = "Error" } | |
} else { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$formData = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | |
$parsedFormData = [System.Web.HttpUtility]::ParseQueryString($formData) | |
$commandToRun = $parsedFormData["command"] | |
$capturedOutputForModal = "" | |
$successStatus = $true | |
$confirmationMessage = "Command executed." | |
if ($commandToRun) { | |
Write-Host "`n--- EXECUTING USER COMMAND VIA DASHBOARD (USE WITH CAUTION) ---" -ForegroundColor Yellow | |
Write-Host $commandToRun | |
Write-Host "--- START COMMAND OUTPUT (CONSOLE) ---" -ForegroundColor Cyan | |
$outputLinesForModal = New-Object System.Collections.Generic.List[string] | |
$invokeErrors = New-Object System.Collections.Generic.List[string] | |
$ErrorActionPreferenceBackup = $ErrorActionPreference | |
try { | |
$ErrorActionPreference = 'Continue' # To capture non-terminating errors into $ErrorVariable | |
# Invoke-Command is generally safer for running arbitrary strings as commands | |
# Using -NoNewScope to ensure variables/aliases from the command might persist in the runspace if needed (though usually not for this tool) | |
# The *>&1 redirection attempts to capture all streams into the $commandOutput pipeline | |
$commandOutput = Invoke-Command -ScriptBlock ([scriptblock]::Create($commandToRun)) -ErrorVariable +capturedCmdErrors *>&1 | |
if ($null -ne $commandOutput) { | |
foreach ($item in $commandOutput) { | |
# 1. Write to backend PowerShell console (using default PowerShell formatting) | |
Write-Output $item | |
# 2. Convert to string and add to list for modal output | |
$stringLineForModal = $item | Out-String -Stream | |
$outputLinesForModal.Add($stringLineForModal.TrimEnd()) | |
} | |
} | |
# Check for non-terminating errors captured by -ErrorVariable | |
if ($capturedCmdErrors.Count -gt 0) { | |
foreach($errItem in $capturedCmdErrors){ | |
$errMsg = $errItem | Out-String | |
Write-Warning "Captured non-terminating error: $errMsg" # To console | |
$outputLinesForModal.Add("`nPS ERROR (Non-Terminating): $errMsg") | |
} | |
$successStatus = $false # If any non-terminating errors, mark as not fully successful | |
$confirmationMessage = "Command executed with non-terminating errors." | |
} | |
# $? reflects success of the last statement in the Invoke-Command scriptblock. | |
# It might be true even if $capturedCmdErrors has items. | |
# If $capturedCmdErrors is empty but $? is false, then the last command in the scriptblock failed silently. | |
if (-not $?) { | |
if ($capturedCmdErrors.Count -eq 0) { # No specific errors were captured, but the command indicated failure | |
$outputLinesForModal.Add("`nPS WARNING: The command indicated failure (e.g., via exit code or uncaptured error).") | |
$successStatus = $false | |
$confirmationMessage = "Command completed, but its status indicates potential issues." | |
} | |
} | |
$capturedOutputForModal = $outputLinesForModal -join "`n" | |
if ([string]::IsNullOrWhiteSpace($capturedOutputForModal) -and $successStatus) { | |
$capturedOutputForModal = "[No textual output from command]" | |
} | |
} catch { # Catch for script-terminating errors in Invoke-Command or other setup issues | |
$errorMessage = "TERMINATING ERROR executing command `'$($commandToRun.Substring(0, [System.Math]::Min($commandToRun.Length, 50)))...`': $($_.Exception.Message)" | |
Write-Warning $errorMessage # Also to PS console | |
$capturedOutputForModal = $errorMessage + "`n" + ($_.ScriptStackTrace -join "`n") | |
$successStatus = $false | |
$confirmationMessage = "Command execution failed with a terminating error." | |
} finally { | |
$ErrorActionPreference = $ErrorActionPreferenceBackup | |
} | |
Write-Host "--- END COMMAND OUTPUT (CONSOLE) ---" -ForegroundColor Cyan | |
$body = ConvertTo-Json @{ success = $successStatus; output = $capturedOutputForModal; confirmation = $confirmationMessage } | |
} else { | |
$capturedOutputForModal = "No command received." | |
$successStatus = $false | |
$confirmationMessage = "No command." | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ success = $successStatus; output = $capturedOutputForModal; confirmation = $confirmationMessage } | |
} | |
} | |
} | |
"/resetpassword" { | |
$context.Response.ContentType = "application/json" | |
if ($context.Request.HttpMethod -ne "POST") { | |
$context.Response.StatusCode = 405 | |
$body = ConvertTo-Json @{ error = "POST method required." } | |
} | |
elseif (-not ($Global:ConnectionState.Graph -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ error = "Graph not connected or script in read-only mode." } | |
} | |
else { | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$jsonPayload = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | |
$data = $jsonPayload | ConvertFrom-Json | |
$userUpn = $data.upn | |
$newPassword = $data.newPassword | |
if (-not $userUpn -or -not $newPassword) { | |
throw "UPN and new password must be provided." | |
} | |
Write-Output "Attempting to reset password for user: ${userUpn} using Update-MgUser -PasswordProfile" | |
# Define the PasswordProfile object | |
$passwordProfile = @{ | |
Password = $newPassword | |
ForceChangePasswordNextSignIn = [bool]$data.forceChange | |
} | |
# Use Update-MgUser, which is in the main Microsoft.Graph.Users module | |
Update-MgUser -UserId $userUpn -PasswordProfile $passwordProfile -ErrorAction Stop | |
Write-Output "Password for ${userUpn} reset successfully." | |
$body = ConvertTo-Json @{ status = "Successfully reset password for ${userUpn}." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to reset password. Error: $($_.Exception.Message)" } | |
Write-Warning "Password reset failed for '${userUpn}': $($_.Exception.ToString())" | |
} | |
} | |
} | |
"/getaccountstatus" { | |
$context.Response.ContentType = "application/json" | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$status = Get-MgUser -UserId $userUpn -Property "accountEnabled" -ErrorAction Stop | Select-Object -ExpandProperty AccountEnabled | |
$body = ConvertTo-Json @{ enabled = $status } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get account status: $($_.Exception.Message)"} | |
} | |
} | |
"/setaccountstatus" { | |
$context.Response.ContentType = "application/json" | |
if ($context.Request.HttpMethod -ne "POST") { | |
$context.Response.StatusCode = 405 | |
$body = ConvertTo-Json @{ error = "POST method required." } | |
} | |
elseif (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400 # Bad Request, as a prerequisite is not met | |
$body = ConvertTo-Json @{ error = "Microsoft Graph not connected. Please connect first." } | |
} | |
elseif ($Global:ScriptSettings.accessMode -ne "readwrite") { | |
$context.Response.StatusCode = 403 # Forbidden | |
$body = ConvertTo-Json @{ error = "Operation forbidden: Script is in read-only mode." } | |
} | |
else { | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$enabledState = [System.Convert]::ToBoolean($data.enabled) | |
if (-not $userUpn) { throw "UPN not provided." } | |
Update-MgUser -UserId $userUpn -AccountEnabled:$enabledState -ErrorAction Stop | |
$actionText = if ($enabledState) { "enabled" } else { "disabled" } | |
$statusMessage = "Successfully $actionText account for $userUpn." | |
$body = ConvertTo-Json @{ status = $statusMessage } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to update account status: $($_.Exception.Message)" } | |
} | |
} | |
} | |
"/revokemfa" { | |
$context.Response.ContentType = "application/json" | |
if ($context.Request.HttpMethod -ne "POST") { | |
$context.Response.StatusCode = 405; $body = ConvertTo-Json @{error = "POST required."} | |
} else { | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
if (-not $userUpn) { throw "UPN not provided." } | |
# This command requires the Microsoft.Graph.Users.Actions module | |
$result = Revoke-MgUserSignInSession -UserId $userUpn -ErrorAction Stop | |
if ($result) { | |
$body = ConvertTo-Json @{ status = "Successfully revoked MFA sessions for ${userUpn}." } | |
} else { | |
throw "The Revoke-MgUserSignInSession command did not return the expected success status." | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to revoke MFA sessions: $($_.Exception.Message)" } | |
} | |
} | |
} | |
"/gettenantdomains" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400 | |
# The JS will now handle the CSV fallback if it receives an error, | |
# so sending a clear error is correct. | |
$body = ConvertTo-Json @{ success = $false; error = "Microsoft Graph not connected. Please connect first." } | |
break | |
} | |
try { | |
# This command fetches all domain objects | |
$allDomainObjects = Get-MgDomain -ErrorAction Stop | |
# Get all verified domain names and sort them alphabetically | |
$sortedDomainNames = $allDomainObjects | Where-Object { $_.IsVerified -eq $true } | Select-Object -ExpandProperty Id | Sort-Object | |
# Find the one that is the default for the tenant | |
$defaultDomain = ($allDomainObjects | Where-Object { $_.IsDefault -eq $true }).Id | |
$body = ConvertTo-Json @{ | |
success = $true; | |
domains = @($sortedDomainNames); # Ensure it's always an array | |
defaultDomain = $defaultDomain | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; error = "Could not get tenant domains: $($_.Exception.Message)" } | |
} | |
} | |
"/updateuserdetails" { | |
$context.Response.ContentType = "application/json" | |
$resultsLog = New-Object System.Collections.Generic.List[string] | |
$overallSuccess = $true | |
# 1. Pre-checks for connections and access mode | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ success = $false; error = "Microsoft Graph not connected. Please connect first." } | |
break | |
} | |
if ($Global:ScriptSettings.accessMode -ne "readwrite") { | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ success = $false; error = "Operation forbidden: Script is in read-only mode." } | |
break | |
} | |
try { | |
# 2. Get and parse the request body | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$changes = $data.changes | |
if (-not $userUpn) { throw "Original UPN not provided." } | |
# Get User's Object ID for Graph API calls | |
$userId = (Get-MgUser -UserId $userUpn -Property Id).Id | |
if (-not $userId) { throw "Could not resolve User ID for UPN ${userUpn}" } | |
# Property name mapping to Graph fields | |
$propertyMappings = @{ | |
"JobTitle" = "jobTitle" | |
"Department" = "department" | |
"CompanyName" = "companyName" | |
"PhysicalDeliveryOfficeName" = "officeLocation" | |
"StreetAddress" = "streetAddress" | |
"City" = "city" | |
"State" = "state" | |
"PostalCode" = "postalCode" | |
"Country" = "country" | |
"MobilePhone" = "mobilePhone" | |
"FaxNumber" = "faxNumber" | |
} | |
# 3. Iterate through each change | |
foreach ($property in $changes.PSObject.Properties) { | |
$key = $property.Name | |
$value = $property.Value | |
#Write-Host "Attempting to update property: '${key}'" | |
try { | |
switch ($key) { | |
"Manager" { | |
if ([string]::IsNullOrWhiteSpace($value)) { | |
Remove-MgUserManagerByRef -UserId $userId -Confirm:$false -ErrorAction Stop | |
$resultsLog.Add("Manager: Removed successfully.") | |
} else { | |
$managerObj = Get-MgUser -UserId $value -Property Id -ErrorAction Stop | |
if ($managerObj) { | |
$bodyParam = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/$($managerObj.Id)" } | |
Set-MgUserManagerByRef -UserId $userId -BodyParameter $bodyParam -ErrorAction Stop | |
$resultsLog.Add("Manager: Set to ${value} successfully.") | |
} else { throw "Manager UPN '${value}' not found." } | |
} | |
} | |
"Notes" { | |
$finalValue = if ([string]::IsNullOrWhiteSpace($value)) { $null } else { $value } | |
if ($Global:ConnectionState.Exchange) { | |
Set-User -Identity $userUpn -Notes $finalValue -Confirm:$false -ErrorAction Stop | |
$resultsLog.Add("Notes: Updated successfully via Set-User.") | |
} else { | |
# Use direct Graph API call for AboutMe | |
$uri = "https://graph.microsoft.com/v1.0/users/$userId" | |
$body = @{ aboutMe = $finalValue } | ConvertTo-Json | |
Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body -ContentType "application/json" -ErrorAction Stop | |
$resultsLog.Add("Notes: Updated successfully via Graph 'AboutMe'.") | |
} | |
} | |
"BusinessPhones" { | |
$phoneList = New-Object System.Collections.Generic.List[string] | |
if (-not [string]::IsNullOrWhiteSpace($value)) { | |
# Split the string by comma. This results in an array of strings. | |
$phones = $value -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | |
# Use a loop to add each item. This works correctly for both single-item and multi-item arrays. | |
foreach ($phone in $phones) { | |
$phoneList.Add($phone) | |
} | |
} | |
$uri = "https://graph.microsoft.com/v1.0/users/$userId" | |
$body = @{ businessPhones = $phoneList } | ConvertTo-Json -Depth 3 | |
Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body -ContentType "application/json" -ErrorAction Stop | |
$resultsLog.Add("BusinessPhones: Updated successfully.") | |
} | |
default { | |
$finalValue = if ([string]::IsNullOrWhiteSpace($value)) { $null } else { $value } | |
# Get Graph property name from mapping | |
$graphProperty = if ($propertyMappings.ContainsKey($key)) { | |
$propertyMappings[$key] | |
} else { | |
# Convert to camelCase if not in mapping | |
$key[0].ToString().ToLower() + $key.Substring(1) | |
} | |
# Use direct Graph API call | |
$uri = "https://graph.microsoft.com/v1.0/users/$userId" | |
$body = @{ $graphProperty = $finalValue } | ConvertTo-Json | |
Write-Host "Graph API Request: $body" | |
Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body -ContentType "application/json" -ErrorAction Stop | |
$resultsLog.Add("${key}: Updated successfully.") | |
} | |
} | |
} catch { | |
$errorMsg = "Failed to update '${key}': $($_.Exception.Message)" | |
Write-Warning $errorMsg | |
$resultsLog.Add("ERROR: $errorMsg") | |
$overallSuccess = $false | |
} | |
} | |
# 4. Construct final response | |
$finalStatusMessage = "User details update process for ${userUpn} " | |
if ($overallSuccess) { | |
$finalStatusMessage += "completed." | |
} else { | |
$finalStatusMessage += "completed with errors." | |
$context.Response.StatusCode = 207 | |
} | |
$body = ConvertTo-Json @{ success = $overallSuccess; status = $finalStatusMessage; details = ($resultsLog -join [System.Environment]::NewLine) } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; error = "A critical error occurred while processing update request: $($_.Exception.Message)"} | |
} | |
} | |
"/getusergroups" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Graph not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$memberOf = Get-MgUserMemberOf -UserId $userUpn -All -ErrorAction SilentlyContinue | |
$groupNames = @() | |
if ($memberOf) { | |
foreach ($item in $memberOf) { | |
if ($item.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') { | |
# The item itself from Get-MgUserMemberOf might have DisplayName directly | |
if ($item.PSObject.Properties.Name -contains 'DisplayName' -and -not([string]::IsNullOrEmpty($item.DisplayName))) { | |
$groupNames += $item.DisplayName | |
} else { | |
# If not, you might need to get the full group object, but DisplayName is usually there for group objects | |
try { | |
$groupObj = Get-MgGroup -GroupId $item.Id -Property DisplayName -ErrorAction SilentlyContinue | |
if ($groupObj -and $groupObj.DisplayName) { | |
$groupNames += $groupObj.DisplayName | |
} | |
} catch { Write-Warning "Could not retrieve display name for group ID $($item.Id)"} | |
} | |
} | |
} | |
} | |
$body = ConvertTo-Json @{ groups = @($groupNames | Sort-Object) } | |
$body = ConvertTo-Json @{ groups = @($groups) } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get user groups: $($_.Exception.Message)" } | |
} | |
} | |
"/searchgroups" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Graph not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$searchQuery = $parsedQuery["query"] | |
if (-not $searchQuery) { throw "Search query not provided." } | |
$allFoundGroups = @{} | |
# Search Graph API (finds M365 Groups and Security Groups) | |
if ($Global:ConnectionState.Graph) { | |
$graphGroups = Get-MgGroup -Filter "startswith(displayName, '$searchQuery') or startswith(mail, '$searchQuery')" -Top 20 -ErrorAction SilentlyContinue | Select-Object Id, DisplayName | |
if ($graphGroups) { | |
foreach ($group in $graphGroups) { | |
if (-not $allFoundGroups.ContainsKey($group.DisplayName)) { | |
$allFoundGroups[$group.DisplayName] = @{ Id = $group.Id; DisplayName = $group.DisplayName } | |
} | |
} | |
} | |
} | |
# Search Exchange (finds M365 Groups and Distribution Lists) | |
if ($Global:ConnectionState.Exchange) { | |
$exchangeGroups = Get-Recipient -Filter "DisplayName -like '$searchQuery*' -or Alias -like '$searchQuery*'" -RecipientTypeDetails MailUniversalDistributionGroup, GroupMailbox -ResultSize 20 -ErrorAction SilentlyContinue | |
if ($exchangeGroups) { | |
foreach ($group in $exchangeGroups) { | |
if (-not $allFoundGroups.ContainsKey($group.DisplayName)) { | |
# We may not have the Graph ID here, but we have the name | |
$allFoundGroups[$group.DisplayName] = @{ Id = $null; DisplayName = $group.DisplayName } | |
} | |
} | |
} | |
} | |
# Combine and sort the results | |
$finalGroupList = $allFoundGroups.Values | Sort-Object DisplayName | |
$body = ConvertTo-Json $finalGroupList | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not search groups: $($_.Exception.Message)" } | |
} | |
} | |
"/updategroupmemberships" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Graph -and $Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ error = "Graph, Exchange Online, and Read-Write mode are required for this operation." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
if (-not $userUpn) { throw "User UPN not provided." } | |
$userId = (Get-MgUser -UserId $userUpn -Property Id -ErrorAction Stop).Id | |
if (-not $userId) { throw "Could not find user ID for ${userUpn}" } | |
$groupsToAdd = $data.groupsToAdd | |
$groupsToRemove = $data.groupsToRemove | |
$resultsLog = New-Object System.Collections.Generic.List[string] | |
$overallSuccess = $true | |
# --- Process Removals (Robust Hybrid Method) --- | |
foreach ($groupName in $groupsToRemove) { | |
try { | |
$removed = $false | |
# Attempt 1: Try Exchange cmdlets first, as they handle DLs and Mail-Enabled Security Groups. | |
try { | |
Remove-DistributionGroupMember -Identity $groupName -Member $userUpn -BypassSecurityGroupManagerCheck -Confirm:$false -ErrorAction Stop | |
$removed = $true | |
$resultsLog.Add("Successfully removed user from Exchange Group '${groupName}'.") | |
} catch { | |
# If it fails, it might be a pure Security or M365 Group, so we try Graph next. | |
# This catch block is intentionally empty so we can fall through to the next attempt. | |
} | |
# Attempt 2: Fallback to Graph API for Security Groups and M365 Groups. | |
if (-not $removed) { | |
$group = Get-MgGroup -Filter "displayName eq '$groupName'" -ErrorAction Stop | Select-Object -First 1 | |
if (-not $group) { throw "Group '$groupName' not found via Graph." } | |
Remove-MgGroupMemberByRef -GroupId $group.Id -DirectoryObjectId $userId -ErrorAction Stop | |
$resultsLog.Add("Successfully removed user from Graph Group '${groupName}'.") | |
} | |
} catch { | |
$errorMsg = "ERROR removing from '${groupName}': $($_.Exception.Message)" | |
Write-Warning $errorMsg; $resultsLog.Add($errorMsg); $overallSuccess = $false | |
} | |
} | |
# --- Process Additions (Robust Hybrid Method) --- | |
foreach ($groupName in $groupsToAdd) { | |
try { | |
$added = $false | |
# Attempt 1: Try Exchange cmdlets first. | |
try { | |
Add-DistributionGroupMember -Identity $groupName -Member $userUpn -Confirm:$false -ErrorAction Stop | |
$added = $true | |
$resultsLog.Add("Successfully added user to Exchange Group '${groupName}'.") | |
} catch { | |
# Fall through to the Graph attempt if the Exchange command fails. | |
} | |
# Attempt 2: Fallback to Graph API. | |
if (-not $added) { | |
$group = Get-MgGroup -Filter "displayName eq '$groupName'" -ErrorAction Stop | Select-Object -First 1 | |
if (-not $group) { throw "Group '$groupName' not found via Graph." } | |
$newMember = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$userId" } | |
New-MgGroupMember -GroupId $group.Id -BodyParameter $newMember -ErrorAction Stop | |
$resultsLog.Add("Successfully added user to Graph Group '${groupName}'.") | |
} | |
} catch { | |
$errorMsg = "ERROR adding to '${groupName}': $($_.Exception.Message)" | |
Write-Warning $errorMsg; $resultsLog.Add($errorMsg); $overallSuccess = $false | |
} | |
} | |
$finalStatus = "Group membership update for ${userUpn} " | |
if ($overallSuccess) { $finalStatus += "completed." } | |
else { $finalStatus += "completed with one or more errors."; $context.Response.StatusCode = 207 } | |
$body = ConvertTo-Json @{ status = $finalStatus; details = ($resultsLog -join [System.Environment]::NewLine) } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to update groups: $($_.Exception.Message)" } | |
} | |
} | |
"/getuserlicenses" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Graph not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
# Wrap the command in @() to force the result into an array | |
$assignedLicenses = @(Get-MgUserLicenseDetail -UserId $userUpn -ErrorAction Stop) | |
$licenseOutput = @() | |
if ($assignedLicenses) { | |
foreach ($lic in $assignedLicenses) { | |
$licenseOutput += @{ | |
skuId = $lic.SkuId.ToString() | |
friendlyName = (Get-FriendlyLicenseName -SkuIdentifier $lic.SkuId.ToString()) | |
skuPartNumber = $lic.SkuPartNumber | |
} | |
} | |
} | |
$body = ConvertTo-Json @{ licenses = @($licenseOutput | Sort-Object friendlyName) } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get user licenses: $($_.Exception.Message)" } | |
} | |
} | |
"/gettenantlicenses" { | |
$context.Response.ContentType = "application/json" | |
# Check if Graph is connected BEFORE trying any Graph commands | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ error = "Microsoft Graph not connected. Please connect first." } | |
# This 'break' was missing in spirit, now ensured by the 'else' | |
} else { | |
# This 'else' block ensures the following code only runs if Graph IS connected | |
try { | |
$subscribedSkus = Get-MgSubscribedSku -All -ErrorAction Stop | |
$licenseOutput = @() | |
if ($subscribedSkus) { | |
foreach ($sku in $subscribedSkus) { | |
$totalUnits = 0 | |
if ($sku.PrepaidUnits -and $null -ne $sku.PrepaidUnits.Enabled) { | |
$totalUnits = $sku.PrepaidUnits.Enabled | |
} | |
$consumedUnits = $sku.ConsumedUnits | |
$availableUnits = $totalUnits - $consumedUnits | |
# --- START: New resilient name-finding logic --- | |
$finalFriendlyName = "[Unknown License]" # Default value | |
# First, try to get the friendly name using the unique SkuId (GUID) | |
if ($sku.SkuId) { | |
$finalFriendlyName = Get-FriendlyLicenseName -SkuIdentifier $sku.SkuId.ToString() | |
} | |
# If the GUID lookup failed (returned the GUID itself), try the SkuPartNumber | |
$isGuidRegex = "^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" | |
if (($finalFriendlyName -match $isGuidRegex) -and ($sku.SkuPartNumber)) { | |
$nameFromPartNumber = Get-FriendlyLicenseName -SkuIdentifier $sku.SkuPartNumber | |
if ($nameFromPartNumber -ne $sku.SkuPartNumber) { | |
$finalFriendlyName = $nameFromPartNumber | |
} else { | |
$finalFriendlyName = $sku.SkuPartNumber | |
} | |
} | |
# --- END: New resilient name-finding logic --- | |
$licenseOutput += [PSCustomObject]@{ | |
skuId = $sku.SkuId.ToString() | |
skuPartNumber = $sku.SkuPartNumber | |
friendlyName = $finalFriendlyName | |
consumed = $consumedUnits | |
total = $totalUnits | |
available = $availableUnits | |
} | |
} | |
} | |
# Sort the output by friendlyName, case-insensitive, for proper alphabetical order | |
$sortedLicenses = $licenseOutput | Sort-Object friendlyName -Culture "" -CaseSensitive:$false | |
$body = ConvertTo-Json @{ licenses = $sortedLicenses } -Depth 3 | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get tenant licenses: $($_.Exception.Message)" } | |
} | |
} | |
} | |
"/updateuserlicenses" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Graph -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Graph not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$licensesToAddSkuIds = $data.licensesToAdd # Array of SkuId GUIDs | |
$licensesToRemoveSkuIds = $data.licensesToRemove # Array of SkuId GUIDs | |
if (-not $userUpn) { throw "User UPN not provided." } | |
$addLicensesPayload = @() | |
foreach ($skuId in $licensesToAddSkuIds) { | |
$addLicensesPayload += @{ SkuId = $skuId } # For simplicity, all service plans enabled | |
} | |
# Set-MgUserLicense requires -AddLicenses and -RemoveLicenses. | |
# It will overwrite the entire license assignment for the user based on these two parameters. | |
# So, we need to get current licenses, remove the ones in $licensesToRemoveSkuIds, then add the ones in $addLicensesPayload. | |
$currentUserLicensesRaw = Get-MgUserLicenseDetail -UserId $userUpn | |
$finalLicensesToAssign = @() | |
# Start with current licenses not in the remove list | |
if($currentUserLicensesRaw){ | |
foreach($currentLic in $currentUserLicensesRaw){ | |
if($licensesToRemoveSkuIds -notcontains $currentLic.SkuId.ToString()){ | |
$finalLicensesToAssign += @{ SkuId = $currentLic.SkuId.ToString() } | |
} | |
} | |
} | |
# Add new licenses, ensuring no duplicates from current ones we are keeping | |
foreach($licToAdd in $addLicensesPayload){ | |
if($finalLicensesToAssign.SkuId -notcontains $licToAdd.SkuId){ | |
$finalLicensesToAssign += $licToAdd | |
} | |
} | |
# If all licenses are removed and none are added, RemoveLicenses should be all current SkuIds, AddLicenses empty. | |
# If final list is empty, we need to remove all existing. | |
$finalRemoveList = @() | |
if($finalLicensesToAssign.Count -eq 0 -and $currentUserLicensesRaw){ | |
$finalRemoveList = $currentUserLicensesRaw | ForEach-Object {$_.SkuId.ToString()} | |
} else { | |
# This covers cases where we only remove, or remove some and add others. | |
# The ones to truly remove are those that were in current, are in $licensesToRemoveSkuIds, | |
# AND are NOT in the $licensesToAddSkuIds (to handle moving a license) | |
# However, Set-MgUserLicense is more like a "set this state" command. | |
# The $finalLicensesToAssign already reflects the desired end state. | |
# We just need to calculate what needs to be in -RemoveLicenses | |
# These are licenses currently assigned to user that are NOT in $finalLicensesToAssign | |
if($currentUserLicensesRaw){ | |
foreach($currentLic in $currentUserLicensesRaw){ | |
if($finalLicensesToAssign.SkuId -notcontains $currentLic.SkuId.ToString()){ | |
$finalRemoveList += $currentLic.SkuId.ToString() | |
} | |
} | |
} | |
} | |
# Ensure $finalLicensesToAssign only contains SkuId and no disabledPlans (for now) | |
$setLicensePayload = @{ | |
AddLicenses = $finalLicensesToAssign # This is the desired *final* state of assigned licenses | |
RemoveLicenses = @() # This should be an empty array as AddLicenses will define the full set. | |
# Correction: Set-MgUserLicense with -AddLicenses *adds* to existing. | |
# It needs AddLicenses and RemoveLicenses lists. | |
} | |
#Write-Output "Setting licenses for $userUpn. Adding: $($licensesToAddSkuIds -join ', '). Removing: $($licensesToRemoveSkuIds -join ', ')" | |
Write-Output "Licenses updated successfully." | |
Set-MgUserLicense -UserId $userUpn -AddLicenses $addLicensesPayload -RemoveLicenses $licensesToRemoveSkuIds -ErrorAction Stop | |
$body = ConvertTo-Json @{ status = "License assignments updated for $userUpn." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to update licenses: $($_.Exception.Message)" } | |
Write-Warning "License update error for $userUpn : $($_.Exception.Message) - SCRIPTSTACKTRACE: $($_.ScriptStackTrace)" | |
} | |
} | |
"/getmailboxandlicensestatus" { | |
$context.Response.ContentType = "application/json" | |
$recipientTypeDetails = $null | |
$isLicensed = $false # Default to false | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
# Check Exchange Connection for Mailbox Type | |
if (-not $Global:ConnectionState.Exchange) { | |
throw "Exchange Online not connected. Cannot get mailbox details." | |
} | |
try { | |
$mailbox = Get-EXOMailbox -Identity $userUpn -ErrorAction SilentlyContinue # SilentlyContinue to check $mailbox after | |
if ($mailbox) { | |
$recipientTypeDetails = $mailbox.RecipientTypeDetails.ToString() | |
} else { | |
# User might exist but not have an EXO mailbox (e.g. on-prem, or never licensed) | |
$recipientTypeDetails = "None" | |
} | |
} catch { | |
# This catch is for Get-EXOMailbox if it throws for other reasons | |
Write-Warning "Error fetching EXO mailbox details for $userUpn : $($_.Exception.Message)" | |
$recipientTypeDetails = "ErrorFetching" # Indicate an error occurred | |
} | |
# Check Graph Connection for License Status | |
if (-not $Global:ConnectionState.Graph) { | |
Write-Warning "Graph not connected. License status check will assume unlicensed." | |
# isLicensed remains $false | |
} else { | |
try { | |
# We'll consider user licensed if they have any license. | |
# A more granular check for specific Exchange SKUs could be done but is more complex. | |
$userLicenses = Get-MgUserLicenseDetail -UserId $userUpn -ErrorAction SilentlyContinue | |
if ($userLicenses -and $userLicenses.Count -gt 0) { | |
$isLicensed = $true | |
} | |
} catch { | |
Write-Warning "Error fetching license details via Graph for $userUpn : $($_.Exception.Message)" | |
# isLicensed remains $false | |
} | |
} | |
$body = ConvertTo-Json @{ recipientTypeDetails = $recipientTypeDetails; isLicensed = $isLicensed } | |
} catch { | |
$context.Response.StatusCode = 400 # Changed to 400 for client errors like missing UPN or service connection issues | |
$body = ConvertTo-Json @{ error = "Could not get mailbox/license status: $($_.Exception.Message)" } | |
} | |
} | |
"/changemailboxtype" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ error = "Exchange Online not connected or script in read-only mode." } | |
break # Exit the case | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$targetType = $data.targetType # Expected "User" or "Shared" | |
if (-not $userUpn) { throw "User UPN not provided." } | |
if (-not ($targetType -eq "Regular" -or $targetType -eq "Shared")) { | |
throw "Invalid target mailbox type specified: '$targetType'. Must be 'User' or 'Shared'." | |
} | |
Write-Output "Attempting to change mailbox type for $userUpn to $targetType" | |
Set-Mailbox -Identity $userUpn -Type $targetType -ErrorAction Stop | |
Write-Output "Mailbox type successfully changed to ${targetType}" | |
$body = ConvertTo-Json @{ status = "Mailbox for $userUpn converted to $targetType successfully." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to change mailbox type: $($_.Exception.Message)" } | |
Write-Warning "Change mailbox type error for $userUpn : $($_.Exception.Message) - SCRIPTSTACKTRACE: $($_.ScriptStackTrace)" | |
} | |
} | |
"/getoofsettings" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Exchange) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Exchange Online not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$oofConfig = Get-MailboxAutoReplyConfiguration -Identity $userUpn -ErrorAction Stop | |
$body = ConvertTo-Json @{ | |
autoReplyState = $oofConfig.AutoReplyState.ToString() # Disabled, Enabled, Scheduled | |
externalAudience = $oofConfig.ExternalAudience.ToString() # None, Known, All | |
startTime = if ($oofConfig.StartTime) { Format-IsoDateLocal -DateTimeValue $oofConfig.StartTime } else { $null } | |
endTime = if ($oofConfig.EndTime) { Format-IsoDateLocal -DateTimeValue $oofConfig.EndTime } else { $null } | |
internalMessage = $oofConfig.InternalMessage | |
externalMessage = $oofConfig.ExternalMessage | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get OOF settings: $($_.Exception.Message)" } | |
} | |
} | |
"/setoofsettings" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ error = "Exchange Online not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
if (-not $userUpn) { throw "User UPN not provided." } | |
$params = @{ | |
Identity = $userUpn | |
AutoReplyState = $data.autoReplyState | |
ExternalAudience = $data.externalAudience | |
InternalMessage = $data.internalMessage | |
ExternalMessage = $data.externalMessage | |
} | |
if ($data.autoReplyState -eq "Scheduled") { | |
if (-not $data.startTime -or -not $data.endTime) { | |
throw "Start and End times are required for scheduled autoreplies." | |
} | |
$params.Add("StartTime", ([datetime]$data.startTime)) | |
$params.Add("EndTime", ([datetime]$data.endTime)) | |
} else { | |
# For "Enabled" or "Disabled", ensure any existing schedule is cleared. | |
# Set-MailboxAutoReplyConfiguration handles this automatically when State is not Scheduled. | |
# Explicitly setting to $null could also be done if issues arise: | |
# $params.Add("StartTime", $null) | |
# $params.Add("EndTime", $null) | |
} | |
Write-Output "Setting OOF for $userUpn. State: $($params.AutoReplyState)" | |
Set-MailboxAutoReplyConfiguration @params -ErrorAction Stop | |
$body = ConvertTo-Json @{ status = "Autoreply settings updated successfully for $userUpn." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to set OOF settings: $($_.Exception.Message)" } | |
Write-Warning "Set OOF error for $userUpn : $($_.Exception.Message) - SCRIPTSTACKTRACE: $($_.ScriptStackTrace)" | |
} | |
} | |
"/getdelegationandforwardingsettings" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Exchange) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ success = $false; error = "Exchange Online not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
Write-Host "DEBUG DELEGATION: Getting settings for ${userUpn}" | |
# Explicitly request all needed properties | |
$mailbox = Get-EXOMailbox -Identity $userUpn -Properties "GrantSendOnBehalfTo", "ForwardingSmtpAddress", "DeliverToMailboxAndForward" -ErrorAction Stop | |
# --- Full Access Permissions --- | |
$fullAccessResult = Get-EXOMailboxPermission -Identity $userUpn -ErrorAction SilentlyContinue | | |
Where-Object { | |
$_.AccessRights -contains "FullAccess" -and | |
$_.IsInherited -eq $false -and | |
$_.User -ne "NT AUTHORITY\SELF" | |
} | |
$fullAccessUsers = @() | |
if ($fullAccessResult) { | |
foreach ($entry in $fullAccessResult) { | |
$userName = $entry.User | |
if ($userName -and $userName -ne "NT AUTHORITY\SELF") { | |
$fullAccessUsers += $userName.ToString() | |
} | |
} | |
} | |
# --- Send As Permissions --- | |
$sendAsResult = Get-RecipientPermission -Identity $userUpn -ErrorAction SilentlyContinue | | |
Where-Object { | |
$_.Trustee -ne "NT AUTHORITY\SELF" -and | |
$_.IsInherited -eq $false | |
} | |
$sendAsUsers = @() | |
if ($sendAsResult) { | |
foreach ($entry in $sendAsResult) { | |
$trustee = $entry.Trustee | |
if ($trustee) { | |
$sendAsUsers += $trustee.ToString() | |
} | |
} | |
} | |
# --- Send on Behalf --- | |
$sendOnBehalfTo = @() | |
if ($mailbox.PSObject.Properties.Name -contains 'GrantSendOnBehalfTo' -and $null -ne $mailbox.GrantSendOnBehalfTo) { | |
$sendOnBehalfTo = @($mailbox.GrantSendOnBehalfTo | ForEach-Object { $_.ToString() }) | |
} | |
$body = ConvertTo-Json @{ | |
success = $true; | |
delegations = @{ | |
fullAccess = $fullAccessUsers | |
sendAs = $sendAsUsers | |
sendOnBehalf = $sendOnBehalfTo | |
} | |
forwarding = @{ | |
forwardingSmtpAddress = if ($mailbox.ForwardingSmtpAddress) { $mailbox.ForwardingSmtpAddress.ToString() -replace '^smtp:' } else { $null } | |
deliverToMailboxAndForward = [bool]$mailbox.DeliverToMailboxAndForward | |
} | |
} -Depth 3 | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; error = "Could not get delegation/forwarding settings: $($_.Exception.Message)" } | |
} | |
} | |
"/updatedelegationandforwardingsettings" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ success = $false; error = "Exchange Online not connected or script in read-only mode." } | |
break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
if (-not $userUpn) { throw "User UPN not provided." } | |
$delegations = $data.delegations | |
$forwarding = $data.forwarding | |
$resultsLog = New-Object System.Collections.Generic.List[string] | |
$overallSuccess = $true | |
# --- Process Delegations --- | |
if ($null -ne $delegations) { | |
# Process Removals first | |
foreach ($item in $delegations.remove) { | |
try { | |
Write-Host "Attempting to remove $($item.type) from $($item.delegate) for ${userUpn}" | |
switch ($item.type) { | |
"FullAccess" { Remove-MailboxPermission -Identity $userUpn -User $item.delegate -AccessRights FullAccess -Confirm:$false -InheritanceType All -ErrorAction Stop } | |
"SendAs" { Remove-RecipientPermission -Identity $userUpn -Trustee $item.delegate -AccessRights SendAs -Confirm:$false -ErrorAction Stop } | |
"SendOnBehalf" { Set-Mailbox -Identity $userUpn -GrantSendOnBehalfTo @{Remove=$item.delegate} -Confirm:$false -ErrorAction Stop } | |
} | |
$resultsLog.Add("Removed $($item.type) from $($item.delegate) successfully.") | |
} catch { | |
$errorMsg = "ERROR removing $($item.type) from $($item.delegate): $($_.Exception.Message)" | |
Write-Warning $errorMsg | |
$resultsLog.Add($errorMsg) | |
$overallSuccess = $false | |
} | |
} | |
# Process Additions | |
foreach ($item in $delegations.add) { | |
try { | |
Write-Host "Attempting to add $($item.type) to $($item.delegate) for ${userUpn}" | |
switch ($item.type) { | |
"FullAccess" { Add-MailboxPermission -Identity $userUpn -User $item.delegate -AccessRights FullAccess -AutoMapping ([bool]$item.automap) -Confirm:$false -ErrorAction Stop } | |
"SendAs" { Add-RecipientPermission -Identity $userUpn -Trustee $item.delegate -AccessRights SendAs -Confirm:$false -ErrorAction Stop } | |
"SendOnBehalf" { Set-Mailbox -Identity $userUpn -GrantSendOnBehalfTo @{Add=$item.delegate} -Confirm:$false -ErrorAction Stop } | |
} | |
$resultsLog.Add("Added $($item.type) to $($item.delegate) successfully.") | |
} catch { | |
$errorMsg = "ERROR adding $($item.type) to $($item.delegate): $($_.Exception.Message)" | |
Write-Warning $errorMsg | |
$resultsLog.Add($errorMsg) | |
$overallSuccess = $false | |
} | |
} | |
} | |
# --- Process Forwarding --- | |
if ($null -ne $forwarding) { | |
try { | |
if ($forwarding.enabled) { | |
if (-not $forwarding.forwardTo) { throw "Forwarding address not specified."} | |
$fwdToIdentity = $forwarding.forwardTo | |
Write-Host "Attempting to enable forwarding for ${userUpn} to ${fwdToIdentity}" | |
Set-Mailbox -Identity $userUpn -ForwardingSmtpAddress $fwdToIdentity -DeliverToMailboxAndForward ([bool]$forwarding.deliverToMailboxAndForward) -Confirm:$false -ErrorAction Stop | |
$resultsLog.Add("Enabled forwarding to ${fwdToIdentity}.") | |
} else { # Disable forwarding | |
Write-Host "Attempting to disable forwarding for ${userUpn}" | |
Set-Mailbox -Identity $userUpn -ForwardingSmtpAddress $null -DeliverToMailboxAndForward $false -Confirm:$false -ErrorAction Stop | |
$resultsLog.Add("Disabled forwarding.") | |
} | |
} catch { | |
$errorMsg = "ERROR setting forwarding: $($_.Exception.Message)" | |
Write-Warning $errorMsg | |
$resultsLog.Add($errorMsg) | |
$overallSuccess = $false | |
} | |
} | |
# --- Construct final response --- | |
$finalStatusMessage = "Operations for ${userUpn} " | |
if ($overallSuccess) { | |
$finalStatusMessage += "completed." | |
} else { | |
$finalStatusMessage += "completed with one or more errors." | |
$context.Response.StatusCode = 207 # Multi-Status | |
} | |
$body = ConvertTo-Json @{ success = $overallSuccess; status = $finalStatusMessage; details = ($resultsLog -join [System.Environment]::NewLine) } | |
} catch { # Catch for overall endpoint issues like JSON parsing or missing UPN | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; error = "Failed to update settings: $($_.Exception.Message)" } | |
Write-Warning "Critical error in /updatedelegationandforwardingsettings for $($data.upn): $($_.Exception.Message)" | |
} | |
} | |
"/getuserteamsphonesettings" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Teams) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Microsoft Teams not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$csUser = Get-CsOnlineUser -Identity $userUpn -ErrorAction Stop | |
$numberType = "[Not Fetched]" | |
$graphUserId = $null | |
if ($csUser.LineURI) { | |
try { | |
# Get-CsPhoneNumberAssignment requires Graph connection for User ID typically | |
if ($Global:ConnectionState.Graph) { | |
$graphUser = Get-MgUser -UserId $userUpn -Property Id -ErrorAction SilentlyContinue | |
if ($graphUser) { | |
$graphUserId = $graphUser.Id | |
$phoneAssignment = Get-CsPhoneNumberAssignment -Identity $userUpn -ErrorAction SilentlyContinue # Some versions use -Identity, others -AssignedPstnTargetId | |
if (!$phoneAssignment -and $graphUserId) { # Try with AssignedPstnTargetId if Identity fails | |
$phoneAssignment = Get-CsPhoneNumberAssignment -AssignedPstnTargetId $graphUserId -ErrorAction SilentlyContinue | |
} | |
if ($phoneAssignment -and $phoneAssignment.NumberType) { | |
$numberType = $phoneAssignment.NumberType.ToString() | |
} | |
} else { | |
Write-Warning "Could not fetch Graph User ID for $userUpn to get number type." | |
} | |
} else { | |
Write-Warning "Graph not connected, cannot fetch detailed number type for $userUpn." | |
$numberType = "[Graph N/C]" | |
} | |
} catch { | |
Write-Warning "Error fetching number type for ${userUpn}: $($_.Exception.Message)" | |
$numberType = "[Error]" | |
} | |
} else { | |
$numberType = "[No Number]" | |
} | |
$body = ConvertTo-Json @{ | |
lineUri = $csUser.LineURI | |
numberType = $numberType | |
enterpriseVoiceEnabled = [bool]$csUser.EnterpriseVoiceEnabled | |
teamsCallingPolicy = $csUser.TeamsCallingPolicy | |
teamsUpgradePolicy = $csUser.TeamsUpgradeEffectiveMode # Or TeamsUpgradePolicy if you prefer assigned policy name | |
tenantDialPlan = $csUser.TenantDialPlan | |
onlineVoiceRoutingPolicy = $csUser.OnlineVoiceRoutingPolicy | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get user Teams phone settings: $($_.Exception.Message)" } | |
} | |
} | |
"/getteamslislocations" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Teams) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Microsoft Teams not connected." }; break | |
} | |
try { | |
$locations = Get-CsOnlineLisLocation -ErrorAction Stop | Select-Object LocationId, Description, Location | |
$outputLocations = @() | |
foreach($loc in $locations){ | |
$outputLocations += @{ | |
id = $loc.LocationId | |
name = if (-not [string]::IsNullOrWhiteSpace($loc.Description)) { $loc.Description } elseif (-not [string]::IsNullOrWhiteSpace($loc.Location)) { $loc.Location } else { $loc.LocationId } | |
} | |
} | |
$body = ConvertTo-Json @{ locations = $outputLocations } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get Teams LIS locations: $($_.Exception.Message)" } | |
} | |
} | |
"/getavailableteamsnumbers" { # Primarily for Calling Plan numbers | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Teams) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Microsoft Teams not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$locationId = $parsedQuery["locationId"] | |
$params = @{ | |
AssignmentStatus = 'Unassigned' | |
CapabilityContain = 'UserAssignment' | |
InventoryType = 'Subscriber' # For Calling Plan type numbers | |
ErrorAction = 'Stop' | |
} | |
if (-not [string]::IsNullOrWhiteSpace($locationId)) { | |
$params.Add("LocationId", $locationId) | |
} | |
$numbers = Get-CsOnlineTelephoneNumber @params | Select-Object -ExpandProperty TelephoneNumber | |
$body = ConvertTo-Json @{ numbers = @($numbers) } # Ensure it's always an array | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get available Teams numbers: $($_.Exception.Message)" } | |
} | |
} | |
"/getteamspolicies" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Teams) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Microsoft Teams not connected." }; break | |
} | |
try { | |
$callingPolicies = @(Get-CsTeamsCallingPolicy -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Identity) | |
$upgradePolicies = @(Get-CsTeamsUpgradePolicy -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Identity) # These are policy instances | |
# Common static modes for TeamsUpgradePolicy (if dynamic fetch isn't what you want for assignment) | |
# $staticUpgradeModes = @("TeamsOnly", "SfBWithTeamsCollab", "SfBWithTeamsCollabAndMeetings") | |
$dialPlans = @(Get-CsTenantDialPlan -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Identity) | |
$voiceRoutingPolicies = @(Get-CsOnlineVoiceRoutingPolicy -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Identity) | |
$body = ConvertTo-Json @{ | |
callingPolicies = $callingPolicies | |
upgradePolicies = $upgradePolicies # Or use $staticUpgradeModes if preferred | |
dialPlans = $dialPlans | |
voiceRoutingPolicies = $voiceRoutingPolicies | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get Teams policies: $($_.Exception.Message)" } | |
} | |
} | |
"/updateuserteamsphonesettings" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Teams -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ error = "Teams not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$changes = $data.changes | |
if (-not $userUpn) { throw "User UPN not provided." } | |
$resultsLog = @() | |
$setCsUserParams = @{ Identity = $userUpn } | |
$policyGrantFailures = @() | |
# Phone Number and EV (handled by Set-CsUser) | |
if ($changes.PSObject.Properties.Name -contains 'phoneNumber') { | |
if ([string]::IsNullOrWhiteSpace($changes.phoneNumber)) { | |
# Unassign the number | |
$setCsUserParams.Add('LineURI', $null) | |
# If EV is not being explicitly set otherwise, disable it when unassigning a number | |
if (-not ($changes.PSObject.Properties.Name -contains 'enterpriseVoiceEnabled')) { | |
$setCsUserParams.Add('EnterpriseVoiceEnabled', $false) | |
} | |
$resultsLog.Add("LineURI will be unassigned.") | |
} else { | |
# Sanitize the number and add the required tel:+ prefix | |
$cleanedNumber = $changes.phoneNumber.Trim() -replace '^\+|^tel:\+?' # Removes existing +, tel:+, or tel: | |
$finalLineUri = "tel:+" + $cleanedNumber | |
$setCsUserParams.Add('LineURI', $finalLineUri) | |
$resultsLog.Add("LineURI will be set to ${finalLineUri}.") | |
# If EV is not being explicitly set otherwise, enable it when assigning a number | |
if (-not ($changes.PSObject.Properties.Name -contains 'enterpriseVoiceEnabled')) { | |
$setCsUserParams.Add('EnterpriseVoiceEnabled', $true) | |
} | |
} | |
} | |
if ($changes.PSObject.Properties.Name -contains 'enterpriseVoiceEnabled') { | |
$setCsUserParams.Add('EnterpriseVoiceEnabled', [bool]$changes.enterpriseVoiceEnabled) | |
$resultsLog += "EnterpriseVoiceEnabled will be set to $($changes.enterpriseVoiceEnabled)." | |
} | |
# Location ID for new number assignment (can be complex, requires number to be PSTN assignable) | |
# For Calling Plan, Set-CsOnlineVoiceUser -TelephoneNumber -LocationID is often used. | |
# Or if number is already in inventory with location, Set-CsUser with LineURI might inherit it. | |
# This example focuses on Set-CsUser; more complex assignment might need Set-CsOnlinePhoneNumberAssignment. | |
if ($changes.PSObject.Properties.Name -contains 'locationId' -and -not [string]::IsNullOrWhiteSpace($changes.locationId) -and $setCsUserParams.ContainsKey('LineURI') -and $setCsUserParams.LineURI -ne $null) { | |
# For simplicity, this example doesn't directly use locationId with Set-CsUser. | |
# True location assignment might need Set-CsOnlinePhoneNumberAssignment or number pre-configuration. | |
$resultsLog += "Note: Location ID ' $($changes.locationId)' was provided. Manual verification of E911 setup might be needed depending on number type." | |
} | |
if ($setCsUserParams.Count -gt 1) { # More than just Identity | |
try { | |
Set-CsUser @setCsUserParams -ErrorAction Stop | |
$resultsLog += "Set-CsUser executed for LineURI/EnterpriseVoiceEnabled." | |
} catch { | |
$resultsLog += "ERROR applying LineURI/EnterpriseVoiceEnabled: $($_.Exception.Message)" | |
} | |
} | |
# Policies | |
if ($changes.PSObject.Properties.Name -contains 'teamsCallingPolicy') { | |
$policyName = if ($changes.teamsCallingPolicy -eq '_org-default_') { $null } else { $changes.teamsCallingPolicy } | |
try { Grant-CsTeamsCallingPolicy -Identity $userUpn -PolicyName $policyName -ErrorAction Stop; $resultsLog += "TeamsCallingPolicy set." } catch { $policyGrantFailures += "TeamsCallingPolicy: $($_.Exception.Message)" } | |
} | |
if ($changes.PSObject.Properties.Name -contains 'teamsUpgradePolicy') { | |
$policyName = if ($changes.teamsUpgradePolicy -eq '_org-default_') { $null } else { $changes.teamsUpgradePolicy } | |
try { Grant-CsTeamsUpgradePolicy -Identity $userUpn -PolicyName $policyName -ErrorAction Stop; $resultsLog += "TeamsUpgradePolicy set." } catch { $policyGrantFailures += "TeamsUpgradePolicy: $($_.Exception.Message)" } | |
} | |
if ($changes.PSObject.Properties.Name -contains 'tenantDialPlan') { | |
$policyName = if ($changes.tenantDialPlan -eq '_org-default_') { $null } else { $changes.tenantDialPlan } | |
try { Grant-CsTenantDialPlan -Identity $userUpn -PolicyName $policyName -ErrorAction Stop; $resultsLog += "TenantDialPlan set." } catch { $policyGrantFailures += "TenantDialPlan: $($_.Exception.Message)" } | |
} | |
if ($changes.PSObject.Properties.Name -contains 'onlineVoiceRoutingPolicy') { | |
$policyName = if ($changes.onlineVoiceRoutingPolicy -eq '_org-default_') { $null } else { $changes.onlineVoiceRoutingPolicy } | |
try { Grant-CsOnlineVoiceRoutingPolicy -Identity $userUpn -PolicyName $policyName -ErrorAction Stop; $resultsLog += "OnlineVoiceRoutingPolicy set." } catch { $policyGrantFailures += "OnlineVoiceRoutingPolicy: $($_.Exception.Message)" } | |
} | |
$finalStatus = "Teams phone settings update processed for $userUpn." | |
if ($policyGrantFailures.Count -gt 0) { | |
$finalStatus += " Some policy assignments failed: " + ($policyGrantFailures -join '; ') | |
$resultsLog += $policyGrantFailures | |
$context.Response.StatusCode = 207 # Multi-Status | |
$body = ConvertTo-Json @{ status = $finalStatus; details = $resultsLog } | |
} else { | |
$body = ConvertTo-Json @{ status = $finalStatus; details = $resultsLog } | |
} | |
} catch { # Catch for overall endpoint issues | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to update Teams phone settings: $($_.Exception.Message)" } | |
} | |
} | |
"/executeoffboardingtask" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Graph -and $Global:ScriptSettings.accessMode -eq "readwrite")) { # Some tasks might need Exchange too | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ success = $false; message = "Required service not connected or script in read-only mode." } | |
break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$taskName = $data.taskName | |
$params = $data.params # This is a PSCustomObject, access its properties like $params.internalReply | |
if (-not $userUpn) { throw "User UPN not provided for offboarding task." } | |
if (-not $taskName) { throw "Task name not provided for offboarding." } | |
$userId = $null # Will be fetched if needed by Graph cmdlets that require ID | |
Write-Output "Executing offboarding task '$taskName' for user '$userUpn'" | |
$taskMessage = "Task '$taskName' for '$userUpn': " | |
switch ($taskName) { | |
"offboardBlockSignIn" { | |
Update-MgUser -UserId $userUpn -AccountEnabled:$false -ErrorAction Stop | |
$taskMessage += "Account sign-in blocked." | |
} | |
"offboardResetPassword" { | |
# Generate a strong random password | |
$newPassword = -join ((65..90) + (97..122) + (48..57) + (33,35,36,37,38,40,41,42,64) | Get-Random -Count 16 | ForEach-Object {[char]$_}) | |
# Create the required password profile object for the new cmdlet | |
$passwordProfile = @{ | |
Password = $newPassword | |
ForceChangePasswordNextSignIn = $false # Offboarding resets shouldn't force a change | |
} | |
# Use the current, correct cmdlet 'Update-MgUser' | |
Update-MgUser -UserId $userUpn -PasswordProfile $passwordProfile -ErrorAction Stop | |
$taskMessage += "Password reset (new password not returned to UI)." | |
} | |
"offboardRevokeMfa" { | |
# Revoke-MgUserSignInSession is broad. For specific MFA methods, more targeted cmdlets would be used if available & needed. | |
# E.g. Reset-MgUserAuthenticationMethod -UserId $userId -AuthenticationMethodId <methodId_or_all_keyword_if_supported> | |
# For now, using Revoke-MgUserSignInSession again as a general measure. | |
Revoke-MgUserSignInSession -UserId $userUpn -ErrorAction Stop # Or consider Reset-MgUserAuthenticationMethod after getting $userId | |
$taskMessage += "MFA sessions revoked (via general sign-in revocation)." | |
} | |
"offboardRevokeSessions" { | |
Revoke-MgUserSignInSession -UserId $userUpn -ErrorAction Stop | |
$taskMessage += "Sign-in sessions revoked." | |
} | |
"offboardConvertToShared" { | |
if (-not $Global:ConnectionState.Exchange) { throw "Exchange Online not connected for task '$taskName'." } | |
Set-Mailbox -Identity $userUpn -Type Shared -ErrorAction Stop | |
$taskMessage += "Mailbox converted to Shared." | |
} | |
"offboardHideFromGAL" { | |
if (-not $Global:ConnectionState.Exchange) { throw "Exchange Online not connected for task '$taskName'." } | |
Set-Mailbox -Identity $userUpn -HiddenFromAddressListsEnabled $true -ErrorAction Stop | |
$taskMessage += "Mailbox hidden from Global Address List." | |
} | |
"offboardRemoveManager" { | |
# This is the correct cmdlet to remove the manager relationship. | |
# -ErrorAction SilentlyContinue prevents the script from stopping if the user already has no manager. | |
Remove-MgUserManagerByRef -UserId $userUpn -ErrorAction SilentlyContinue -Confirm:$false | |
$taskMessage += "Manager removed." | |
} | |
"offboardRemoveGroups" { | |
$userId = (Get-MgUser -UserId $userUpn -Property Id -ErrorAction Stop).Id | |
if (-not $userId) { throw "Could not get User ID for group removal." } | |
$groups = Get-MgUserMemberOf -UserId $userId -All -ErrorAction SilentlyContinue | |
$groupRemovalLog = New-Object System.Collections.Generic.List[string] | |
if ($null -ne $groups) { | |
foreach ($group in $groups) { | |
# Ensure we are only processing group objects and not other directory roles | |
if ($group.AdditionalProperties.'@odata.type' -ne '#microsoft.graph.group') { | |
continue | |
} | |
$groupDisplayName = $group.DisplayName | |
$groupId = $group.Id | |
$removed = $false | |
# Attempt 1: Exchange - M365 Group | |
if ($Global:ConnectionState.Exchange) { | |
Remove-UnifiedGroupLinks -Identity $groupDisplayName -LinkType Members -Links $userUpn -Confirm:$false -ErrorAction SilentlyContinue | |
if ($?) { $removed = $true } | |
} | |
# Attempt 2: Exchange - Distribution List | |
if (-not $removed -and $Global:ConnectionState.Exchange) { | |
Remove-DistributionGroupMember -Identity $groupDisplayName -Member $userUpn -BypassSecurityGroupManagerCheck -Confirm:$false -ErrorAction SilentlyContinue | |
if ($?) { $removed = $true } | |
} | |
# Attempt 3: Graph API - Universal Fallback (handles Security Groups) | |
if (-not $removed -and $Global:ConnectionState.Graph) { | |
Remove-MgGroupMemberByRef -GroupId $groupId -DirectoryObjectId $userId -ErrorAction SilentlyContinue | |
if ($?) { $removed = $true } | |
} | |
if ($removed) { | |
$groupRemovalLog.Add("Removed from: $($groupDisplayName)") | |
} else { | |
$groupRemovalLog.Add("ERROR removing from: $($groupDisplayName)") | |
} | |
} | |
} else { | |
$groupRemovalLog.Add("User is not a member of any groups.") | |
} | |
$taskMessage += "Group removal process completed. Details: $($groupRemovalLog -join '; ')" | |
} | |
"offboardRemoveLicenses" { | |
# Get detailed license assignment states to check for inheritance | |
$licenseStates = (Get-MgUser -UserId $userUpn -Property licenseAssignmentStates -ErrorAction Stop).LicenseAssignmentStates | |
# Find licenses that are directly assigned (not from a group) | |
$directLicensesToRemove = $licenseStates | Where-Object { -not $_.AssignedByGroup } | Select-Object -ExpandProperty SkuId | |
# Find licenses that are inherited from groups | |
$inheritedLicenses = $licenseStates | Where-Object { $_.AssignedByGroup } | Select-Object -ExpandProperty SkuId | |
$removalLog = New-Object System.Collections.Generic.List[string] | |
# Only attempt to remove the directly assigned licenses | |
if ($directLicensesToRemove.Count -gt 0) { | |
try { | |
Set-MgUserLicense -UserId $userUpn -RemoveLicenses $directLicensesToRemove -AddLicenses @() -ErrorAction Stop | |
$removalLog.Add("Successfully removed $($directLicensesToRemove.Count) directly assigned license(s).") | |
} catch { | |
# This will catch any other errors during removal | |
$removalLog.Add("ERROR removing direct licenses: $($_.Exception.Message)") | |
} | |
} else { | |
$removalLog.Add("No directly assigned licenses found to remove.") | |
} | |
# Report on any inherited licenses that were skipped | |
if ($inheritedLicenses.Count -gt 0) { | |
$friendlyNames = @() | |
foreach($sku in $inheritedLicenses){ | |
# Using the script's existing helper function to get friendly names | |
$friendlyNames += (Get-FriendlyLicenseName -SkuIdentifier $sku) | |
} | |
$removalLog.Add("Skipped $($inheritedLicenses.Count) license(s) inherited from groups: $($friendlyNames -join ', ').") | |
} | |
# Combine the log into the final task message | |
$taskMessage += ($removalLog -join " | ") | |
} | |
"offboardSetupAutoreply" { | |
if (-not $Global:ConnectionState.Exchange) { throw "Exchange Online not connected for task '$taskName'." } | |
$oofParams = @{ Identity = $userUpn; AutoReplyState = "Enabled" } # Default to Enabled if setting up | |
if ($params.PSObject.Properties.Name -contains 'audience') { $oofParams.ExternalAudience = $params.audience } | |
if ($params.PSObject.Properties.Name -contains 'internalReply') { $oofParams.InternalMessage = $params.internalReply } | |
if ($params.PSObject.Properties.Name -contains 'externalReply') { $oofParams.ExternalMessage = $params.externalReply } | |
Set-MailboxAutoReplyConfiguration @oofParams -ErrorAction Stop | |
$taskMessage += "Autoreply configured." | |
} | |
"offboardSetupForwarding" { | |
if (-not $Global:ConnectionState.Exchange) { throw "Exchange Online not connected for task '$taskName'." } | |
if ($null -ne $params.forwardToUpn -and $params.forwardToUpn.Trim() -ne "") { | |
Set-Mailbox -Identity $userUpn -ForwardingSmtpAddress $params.forwardToUpn -DeliverToMailboxAndForward ([bool]$params.deliverAndForward) -ErrorAction Stop | |
$taskMessage += "Forwarding set to $($params.forwardToUpn)." | |
} else { # Clear forwarding if no UPN provided for this task | |
Set-Mailbox -Identity $userUpn -ForwardingSmtpAddress $null -DeliverToMailboxAndForward $false -ErrorAction Stop | |
$taskMessage += "Forwarding cleared." | |
} | |
} | |
"offboardDelegateAccess" { # Assuming Full Access for simplicity based on previous UI label | |
if (-not $Global:ConnectionState.Exchange) { throw "Exchange Online not connected for task '$taskName'." } | |
if ($null -ne $params.delegates -and $params.delegates.Count -gt 0) { | |
foreach ($delegateUpn in $params.delegates) { | |
try { | |
Add-MailboxPermission -Identity $userUpn -User $delegateUpn -AccessRights FullAccess -AutoMapping ([bool]$params.automap) -Confirm:$false -ErrorAction Stop | |
$taskMessage += "Granted Full Access to $delegateUpn. " | |
} catch { | |
$taskMessage += "ERROR granting Full Access to ${delegateUpn}: $($_.Exception.Message). " | |
} | |
} | |
} else { $taskMessage += "No delegates specified for Full Access." } | |
} | |
"offboardRemoveDDI" { | |
if (-not $Global:ConnectionState.Teams) { throw "Microsoft Teams not connected for task '$taskName'."} | |
Set-CsUser -Identity $userUpn -LineURI $null -EnterpriseVoiceEnabled $false -ErrorAction Stop # Also disable EV | |
$taskMessage += "Teams DDI (LineURI) removed and Enterprise Voice disabled." | |
} | |
"offboardRemoveDevices" { | |
$userId = (Get-MgUser -UserId $userUpn -Property Id -ErrorAction Stop).Id | |
if (-not $userId) { throw "Could not get User ID for device removal." } | |
$allDevices = Get-MgUserRegisteredDevice -UserId $userId -All -ErrorAction SilentlyContinue | |
if (!$allDevices) { | |
$taskMessage += "No registered devices found to remove." | |
break # Exit this case since there's nothing to do | |
} | |
$devicesToRemove = $allDevices | |
$removalTypeMessage = "all $($allDevices.Count) associated device(s)" | |
# Check if the mobileOnly parameter was sent from JavaScript and is true | |
if ($params.PSObject.Properties.Name -contains 'mobileOnly' -and [bool]$params.mobileOnly) { | |
$devicesToRemove = $allDevices | Where-Object { $_.OperatingSystem -eq 'iOS' -or $_.OperatingSystem -eq 'Android' } | |
$removalTypeMessage = "$($devicesToRemove.Count) mobile device(s) (iOS/Android)" | |
} | |
if ($devicesToRemove.Count -gt 0) { | |
foreach($device in $devicesToRemove) { | |
try { | |
Remove-MgDevice -DeviceId $device.Id -ErrorAction Stop | |
} catch { | |
Write-Warning "Failed to remove device $($device.Id) ($($device.DisplayName)): $($_.Exception.Message)" | |
} | |
} | |
$taskMessage += "Attempted removal of $removalTypeMessage." | |
} else { | |
# This handles the case where "mobile only" is checked but the user has no mobile devices | |
$taskMessage += "No devices matched the specified criteria to remove." | |
} | |
} | |
"offboardAddNotes" { | |
$newNoteText = if ($params.PSObject.Properties.Name -contains 'notesText') { $params.notesText } else { '' } | |
# Use Set-User via Exchange if connected (more reliable for Notes/Info field) | |
if ($Global:ConnectionState.Exchange) { | |
$currentUser = Get-User -Identity $userUpn -ErrorAction Stop | |
$existingNotes = $currentUser.Notes | |
# Using `r`n for Windows-style line breaks, common in Notes field | |
$combinedNotes = if ($existingNotes) { "$($existingNotes)`r`n$($newNoteText)" } else { $newNoteText } | |
Set-User -Identity $userUpn -Notes $combinedNotes -Confirm:$false -ErrorAction Stop | |
} | |
# Fallback to Graph if Exchange is not connected | |
else { | |
$existingNotes = (Get-MgUser -UserId $userUpn -Property AboutMe -ErrorAction Stop).AboutMe | |
$combinedNotes = if ($existingNotes) { "$($existingNotes)`r`n$($newNoteLine)" } else { $newNoteLine } | |
Update-MgUser -UserId $userUpn -AboutMe $combinedNotes -ErrorAction Stop | |
} | |
$taskMessage += "Offboarding notes added." | |
} | |
default { | |
throw "Unknown offboarding task: '$taskName'." | |
} | |
} | |
$body = ConvertTo-Json @{ success = $true; message = $taskMessage } | |
} catch { | |
$errorMessage = "Error executing task '$($data.taskName)' for '$($data.upn)': $($_.Exception.Message)" | |
Write-Warning $errorMessage | |
$context.Response.StatusCode = 200 # Still sending 200 OK, but with success:false in body | |
$body = ConvertTo-Json @{ success = $false; message = $errorMessage } | |
} | |
} | |
"/onboard/createcoreuser" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Graph -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ success = $false; message = "Graph not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
# Check if user already exists | |
$existingUser = Get-MgUser -UserId $data.userPrincipalName -ErrorAction SilentlyContinue | |
if ($existingUser) { | |
throw "User with UPN '$($data.userPrincipalName)' already exists." | |
} | |
$passwordProfile = @{ | |
ForceChangePasswordNextSignIn = [bool]$data.forceChangePassword | |
Password = $data.password | |
} | |
$userParams = @{ | |
UserPrincipalName = $data.userPrincipalName | |
DisplayName = $data.displayName | |
GivenName = $data.givenName | |
Surname = $data.surname | |
AccountEnabled = $true # Enable account by default | |
PasswordProfile = $passwordProfile | |
UsageLocation = $data.usageLocation # Required for license assignment | |
} | |
# Add optional parameters if provided | |
if ($data.jobTitle) { $userParams.JobTitle = $data.jobTitle } | |
if ($data.department) { $userParams.Department = $data.department } | |
if ($data.companyName) { $userParams.CompanyName = $data.companyName } | |
if ($data.officeLocation) { $userParams.OfficeLocation = $data.officeLocation } # Graph uses OfficeLocation | |
if ($data.streetAddress) { $userParams.StreetAddress = $data.streetAddress } | |
if ($data.city) { $userParams.City = $data.city } | |
if ($data.state) { $userParams.State = $data.state } | |
if ($data.postalCode) { $userParams.PostalCode = $data.postalCode } | |
if ($data.country) { $userParams.Country = $data.country } | |
if ($data.mobilePhone) { $userParams.MobilePhone = $data.mobilePhone } | |
if ($data.businessPhones -and $data.businessPhones.Count -gt 0) { $userParams.BusinessPhones = $data.businessPhones } | |
if ($data.managerUpn) { | |
try { | |
$manager = Get-MgUser -UserId $data.managerUpn -Property Id -ErrorAction Stop | |
if ($manager) { | |
$userParams.Manager = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/$($manager.Id)" } | |
} else { Write-Warning "Manager UPN '$($data.managerUpn)' not found." } | |
} catch { Write-Warning "Error resolving manager UPN '$($data.managerUpn)': $($_.Exception.Message)"} | |
} | |
Write-Output "Creating user with UPN: $($data.userPrincipalName)" | |
New-MgUser @userParams -ErrorAction Stop | |
$body = ConvertTo-Json @{ success = $true; message = "User $($data.userPrincipalName) created." } | |
} catch { | |
if ($_.Exception.ResponseHeaders -and $_.Exception.ResponseHeaders.ContainsKey("Retry-After")) { | |
$context.Response.StatusCode = 429 # Too Many Requests | |
} else { | |
$context.Response.StatusCode = 500 # Internal Server Error | |
} | |
$body = ConvertTo-Json @{ success = $false; message = "Failed to create core user: $($_.Exception.Message)" } | |
} | |
} | |
"/onboard/assignlicenses" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Graph -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ success = $false; message = "Graph not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$licenseSkuIds = @($data.licenseSkuIds) | |
if (-not $userUpn) { throw "UPN not provided." } | |
if ($licenseSkuIds.Count -gt 0) { | |
$addLicensesPayload = $licenseSkuIds | ForEach-Object { @{ SkuId = $_ } } | |
Set-MgUserLicense -UserId $userUpn -AddLicenses $addLicensesPayload -RemoveLicenses @() -ErrorAction Stop | |
$body = ConvertTo-Json @{ success = $true; message = "Licenses assigned to $userUpn." } | |
} else { | |
$body = ConvertTo-Json @{ success = $true; message = "No licenses specified to assign to $userUpn." } | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; message = "Failed to assign licenses: $($_.Exception.Message)" } | |
} | |
} | |
"/getmailboxstatus" { # Checks if an EXO mailbox exists | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Exchange) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ exists = $false; error = "Exchange Online not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$mailbox = Get-EXOMailbox -Identity $userUpn -Properties "GrantSendOnBehalfTo", "ForwardingSmtpAddress", "DeliverToMailboxAndForward" -ErrorAction Stop | |
if ($mailbox) { | |
$body = ConvertTo-Json @{ exists = $true } | |
} else { | |
$body = ConvertTo-Json @{ exists = $false } | |
} | |
} catch { | |
$body = ConvertTo-Json @{ exists = $false; error = "Error checking mailbox status: $($_.Exception.Message)" } | |
} | |
} | |
"/onboard/addtogroup" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Graph -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ success = $false; message = "Graph not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$groupName = $data.groupName | |
if (-not $userUpn -or -not $groupName) { throw "UPN and Group Name required."} | |
$userId = (Get-MgUser -UserId $userUpn -Property Id -ErrorAction Stop).Id | |
if (-not $userId) { throw "Could not find user $userUpn."} | |
$group = Get-MgGroup -Filter "displayName eq '$groupName'" -ErrorAction Stop | Select-Object -First 1 | |
if (-not $group) { throw "Group '$groupName' not found." } | |
$newMember = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$userId" } | |
New-MgGroupMember -GroupId $group.Id -BodyParameter $newMember -ErrorAction Stop # This is for adding a reference | |
$body = ConvertTo-Json @{ success = $true; message = "User $userUpn added to group $groupName." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; message = "Failed to add to group: $($_.Exception.Message)" } | |
} | |
} | |
"/onboard/setdelegate" { # Assuming Full Access for this onboarding step | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ success = $false; message = "Exchange Online not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$targetMailboxUpn = $data.targetMailboxUpn # The new user | |
$delegateToUpn = $data.delegateToUpn # The user who gets access | |
$permissionType = $data.permissionType # Should be 'FullAccess' | |
$automap = [bool]$data.automap | |
if(-not $targetMailboxUpn -or -not $delegateToUpn -or -not $permissionType) { throw "Target UPN, Delegate UPN, and Permission Type required."} | |
if($permissionType -ne "FullAccess") { throw "This endpoint currently only supports 'FullAccess' delegation for onboarding."} | |
Add-MailboxPermission -Identity $targetMailboxUpn -User $delegateToUpn -AccessRights FullAccess -AutoMapping $automap -Confirm:$false -ErrorAction Stop | |
$body = ConvertTo-Json @{ success = $true; message = "Granted $permissionType to $delegateToUpn on $targetMailboxUpn mailbox." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ success = $false; message = "Failed to set delegation: $($_.Exception.Message)" } | |
} | |
} | |
"/getuseraliases" { | |
$context.Response.ContentType = "application/json" # Ensure this is set | |
# NO Write-Host or other outputs before $body is built from ConvertTo-Json | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400; | |
$body = ConvertTo-Json @{ error = "Graph not connected." }; | |
# Add break here if it's not the last statement in an if/else | |
} else { # Added else for clarity | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$user = Get-MgUser -UserId $userUpn -Property "proxyAddresses", "mail", "givenName", "surname" -ErrorAction Stop | |
$primarySmtp = $user.Mail | |
$proxyAddresses = @($user.ProxyAddresses) | |
# In /getuseraliases | |
$body = ConvertTo-Json @{ | |
proxyAddresses = $proxyAddresses; | |
primarySmtp = $primarySmtp; | |
firstName = $user.GivenName; | |
lastName = $user.Surname | |
} | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get user aliases: $($_.Exception.Message)" } | |
} | |
} | |
} | |
"/getsyncstatus" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Graph) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Graph not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
$syncStatus = Get-MgUser -UserId $userUpn -Property "onPremisesSyncEnabled" -ErrorAction Stop | Select-Object -ExpandProperty OnPremisesSyncEnabled | |
$body = ConvertTo-Json @{ onPremisesSyncEnabled = [bool]$syncStatus } # Ensure it's a boolean | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get sync status: $($_.Exception.Message)" } | |
} | |
} | |
"/updateuseraliases" { | |
$context.Response.ContentType = "application/json" | |
# Check for Exchange Online connection, which is REQUIRED for this operation | |
if (-not ($Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403 | |
$body = ConvertTo-Json @{ error = "Exchange Online not connected or script in read-only mode." } | |
break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$newProxyAddresses = @($data.newProxyAddresses) # Ensure it's an array | |
if (-not $userUpn) { throw "User UPN not provided." } | |
# This is the correct cmdlet to set email aliases | |
Set-Mailbox -Identity $userUpn -EmailAddresses $newProxyAddresses -Confirm:$false -ErrorAction Stop | |
$body = ConvertTo-Json @{ status = "Aliases updated successfully for ${userUpn}." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to update aliases: $($_.Exception.Message)" } | |
Write-Warning "Alias update error for ${userUpn} : $($_.Exception.Message)" | |
} | |
} | |
"/getgalvisibility" { | |
$context.Response.ContentType = "application/json" | |
if (-not $Global:ConnectionState.Exchange) { | |
$context.Response.StatusCode = 400; $body = ConvertTo-Json @{ error = "Exchange Online not connected." }; break | |
} | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$userUpn = $parsedQuery["upn"] | |
if (-not $userUpn) { throw "UPN not provided." } | |
# Using your efficient command to get only the property we need | |
$isHidden = Get-Mailbox -Identity $userUpn | Select-Object -ExpandProperty HiddenFromAddressListsEnabled | |
$body = ConvertTo-Json @{ isHidden = [bool]$isHidden } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Could not get GAL visibility: $($_.Exception.Message)" } | |
} | |
} | |
"/togglegalvisibility" { | |
$context.Response.ContentType = "application/json" | |
if (-not ($Global:ConnectionState.Exchange -and $Global:ScriptSettings.accessMode -eq "readwrite")) { | |
$context.Response.StatusCode = 403; $body = ConvertTo-Json @{ error = "Exchange Online not connected or script in read-only mode." }; break | |
} | |
try { | |
$requestBodyBytes = New-Object byte[] $context.Request.ContentLength64 | |
$null = $context.Request.InputStream.Read($requestBodyBytes, 0, $requestBodyBytes.Length) | |
$data = [System.Text.Encoding]::UTF8.GetString($requestBodyBytes) | ConvertFrom-Json | |
$userUpn = $data.upn | |
$newHiddenState = [bool]$data.newState # Receives the new state from JavaScript | |
if (-not $userUpn) { throw "UPN not provided." } | |
# Using your correct Set-Mailbox command | |
Set-Mailbox -Identity $userUpn -HiddenFromAddressListsEnabled $newHiddenState -Confirm:$false -ErrorAction Stop | |
$actionText = if ($newHiddenState) { "hidden from" } else { "made visible in" } | |
$body = ConvertTo-Json @{ status = "Mailbox for ${userUpn} successfully ${actionText} the GAL." } | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = "Failed to toggle GAL visibility: $($_.Exception.Message)" } | |
} | |
} | |
"/getgroupdetails" { | |
$context.Response.ContentType = "application/json" | |
# -- REMOVED the strict connection check from here -- | |
try { | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query) | |
$groupName = $parsedQuery["groupName"] | |
if (-not $groupName) { throw "Group Name not provided." } | |
# This initial lookup requires only a Graph connection. | |
if (-not $Global:ConnectionState.Graph) { | |
throw "A Microsoft Graph connection is required to look up any group." | |
} | |
$group = Get-MgGroup -Filter "displayName eq '$groupName'" -Property "Id,DisplayName,Mail,GroupTypes,SecurityEnabled,Visibility,MembershipRule" -ErrorAction Stop | Select-Object -First 1 | |
if (-not $group) { throw "Group with name '$groupName' not found." } | |
# Initialize variables | |
$owners = @() | |
$members = @() | |
# Determine a clear Group Type string | |
$groupTypesString = "" | |
$isExchangeGroup = $false | |
if ($group.GroupTypes -contains "Unified") { | |
$groupTypesString = "Microsoft 365" | |
} elseif ($group.SecurityEnabled -eq $true -and $group.Mail -ne $null) { | |
$groupTypesString = "Mail-Enabled Security" | |
$isExchangeGroup = $true | |
} elseif ($group.SecurityEnabled -eq $true) { | |
$groupTypesString = "Security" | |
} else { | |
$groupTypesString = "Distribution List" | |
$isExchangeGroup = $true | |
} | |
if ($group.GroupTypes -contains "DynamicMembership") { | |
$groupTypesString += " (Dynamic)" | |
} | |
# --- START: INTELLIGENT CONNECTION CHECK --- | |
if ($isExchangeGroup) { | |
# For Distribution Lists, we now check for the Exchange connection here. | |
if (-not $Global:ConnectionState.Exchange) { | |
throw "An Exchange Online connection is required to get members of this group type." | |
} | |
$members = Get-DistributionGroupMember -Identity $group.Mail | Select-Object -ExpandProperty DisplayName | |
$exchangeGroupOwners = Get-Recipient -Identity $group.Mail | Select-Object -ExpandProperty ManagedBy | |
if ($exchangeGroupOwners) { $owners = $exchangeGroupOwners | ForEach-Object { $_.DisplayName } } | |
} else { | |
# For M365 and Security groups, we only require the Graph connection (already checked above). | |
$uri = "https://graph.microsoft.com/v1.0/groups/$($group.Id)?`$expand=members(`$select=id,displayName)" | |
$apiResult = Invoke-MgGraphRequest -Method Get -Uri $uri -ErrorAction Stop | |
if ($null -ne $apiResult.members) { | |
$members = $apiResult.members | ForEach-Object { $_.displayName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
} | |
$owners = Get-MgGroupOwner -GroupId $group.Id -ErrorAction SilentlyContinue | ForEach-Object { $_.DisplayName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
} | |
# --- END: INTELLIGENT CONNECTION CHECK --- | |
$sortedMembers = @($members | Sort-Object) | |
$details = @{ | |
DisplayName = $group.DisplayName | |
Mail = $group.Mail | |
GroupTypes = $groupTypesString | |
Visibility = $group.Visibility | |
IsDynamic = $group.GroupTypes -contains "DynamicMembership" | |
MembershipRule = $group.MembershipRule | |
Owners = @($owners | Sort-Object) | |
Members = $sortedMembers | |
MemberCount = $sortedMembers.Count | |
} | |
$body = ConvertTo-Json $details -Depth 5 | |
} catch { | |
$context.Response.StatusCode = 500 | |
# Return the error message in the standard JSON format | |
$body = ConvertTo-Json @{ error = "Could not get group details: $($_.Exception.Message)" } | |
} | |
} | |
"/getsharedmailboxaccess" { | |
$context.Response.ContentType = "application/json" | |
$parsedQuery = [System.Web.HttpUtility]::ParseQueryString($query) | |
$userUpn = $parsedQuery["upn"] | |
$body = "" | |
$accessibleMailboxes = @() | |
if (-not $userUpn) { | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ error = "UPN not provided for shared access lookup." } | |
} elseif (-not $Global:ConnectionState.Exchange) { | |
$context.Response.StatusCode = 400 | |
$body = ConvertTo-Json @{ error = "Exchange Online not connected. Please connect to Exchange first." } | |
} else { | |
Write-Output "DEBUG PS: /getsharedmailboxaccess called for UPN: $userUpn" | |
try { | |
$null = Import-Module ExchangeOnlineManagement -ErrorAction SilentlyContinue # Ensure module is loaded in this scope | |
Write-Output "DEBUG PS: Getting all mailboxes to check permissions for $userUpn (this may take time)..." | |
# Fetch only necessary properties to speed up Get-Mailbox | |
$allMailboxes = Get-Mailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox,SharedMailbox -ErrorAction SilentlyContinue | |
if ($null -ne $allMailboxes) { | |
foreach ($mbx in $allMailboxes) { | |
$mailboxIdentity = $mbx.PrimarySmtpAddress | |
try { | |
# Get-MailboxPermission can be slow if run for every mailbox. | |
# Consider alternative approaches if performance is critical for very large environments. | |
$permissions = Get-MailboxPermission -Identity $mailboxIdentity -User $userUpn -ErrorAction SilentlyContinue | |
if ($permissions | Where-Object {$_.AccessRights -match "FullAccess" -and $_.IsInherited -eq $false}) { | |
$accessibleMailboxes += $mbx.DisplayName | |
} | |
} catch { | |
# Log minor errors but continue; e.g. if a mailbox is weirdly configured. | |
Write-Warning ("Minor error checking permission for mailbox '{0}' for user '{1}': {2}" -f $mailboxIdentity, $userUpn, $_.Exception.Message) | |
} | |
} | |
} else { | |
Write-Warning "Get-Mailbox returned no mailboxes to check for permissions for $userUpn." | |
} | |
$body = ConvertTo-Json @{ AccessibleMailboxes = ($accessibleMailboxes | Sort-Object) } | |
Write-Output "DEBUG PS: Found $($accessibleMailboxes.Count) mailboxes with full access for $userUpn" | |
} catch { | |
$context.Response.StatusCode = 500 | |
$body = ConvertTo-Json @{ error = ("Error fetching shared access for {0}: {1}" -f $userUpn, $_.Exception.Message)} | |
Write-Warning ("Error in /getsharedmailboxaccess for {0}: {1}" -f $userUpn, $_.Exception.ToString()) | |
} | |
} | |
if (-not $body) { # Ensure body is always set, even if it's an empty array | |
$body = ConvertTo-Json @{ AccessibleMailboxes = $accessibleMailboxes } | |
} | |
} | |
default { | |
$context.Response.StatusCode = 404 | |
$body = "404 Not Found: Path '$path' not recognized." | |
} | |
} | |
} catch { | |
# General error handler for the main switch statement | |
$context.Response.StatusCode = 500 | |
$body = "An unexpected error occurred on the server: $($_.Exception.Message)" | |
Write-Error ("Unhandled Switch Error for path '{0}': {1}" -f $path, $_.Exception.ToString()) | |
} | |
# Send response | |
if ($body) { | |
try { | |
# If an error status code is set and body looks like JSON but content type is plain, fix it | |
if ($context.Response.StatusCode -ge 400 -and $context.Response.ContentType -eq "text/plain" -and $body.TrimStart().StartsWith("{") -and $body.TrimEnd().EndsWith("}")) { | |
$context.Response.ContentType = "application/json" | |
} | |
$buffer = [System.Text.Encoding]::UTF8.GetBytes($body) | |
$context.Response.ContentLength64 = $buffer.Length | |
$context.Response.OutputStream.Write($buffer, 0, $buffer.Length) | |
} catch { | |
Write-Error ("Error writing response: " + $_.Exception.Message) | |
} finally { | |
if ($context.Response.OutputStream.CanWrite) { | |
$context.Response.OutputStream.Close() | |
} | |
} | |
} elseif ($context.Response.OutputStream.CanWrite) { # Ensure stream is closed even if no body | |
$context.Response.OutputStream.Close() | |
} | |
} | |
# === Stop the web UI server and transition to interactive PowerShell console =============================================================================================================================== | |
Write-Output "Stopping listener..." | |
if ($listener -and $listener.IsListening) { | |
$listener.Stop() | |
} | |
if ($listener) { | |
$listener.Close() | |
} | |
Write-Host "`n----------------------------------------------------------------" -ForegroundColor Green | |
Write-Host "UI server has been stopped. The PowerShell console is now active." -ForegroundColor Green | |
Write-Host "Your M365 connections are still active in this session." -ForegroundColor Green | |
Write-Host "You can now run commands directly here. Type 'exit' to close this window." -ForegroundColor Green | |
Write-Host "----------------------------------------------------------------`n" -ForegroundColor Green | |
while ($true) { | |
try { | |
$command = Read-Host "PS $(Get-Location)>" | |
if ($command.ToLower() -eq 'exit') { | |
break # Exit the loop and the script | |
} | |
if (-not ([string]::IsNullOrWhiteSpace($command))) { | |
Invoke-Expression $command | Out-Default | |
} | |
} catch { | |
Write-Warning $_.Exception.Message | |
} | |
} | |
# Script ends, and control returns to the PowerShell host prompt. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment