Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save theguynamedjake/79444c99acf0f11b8344fdc8f7fca092 to your computer and use it in GitHub Desktop.
Save theguynamedjake/79444c99acf0f11b8344fdc8f7fca092 to your computer and use it in GitHub Desktop.
UserDetailsDashboard.ps1
# 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 = '&nbsp;';
}
else {
el.innerHTML = '&nbsp;';
}
} 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 = "&nbsp;";
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) /* &nbsp; */) {
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">&nbsp;</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">&nbsp;</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">&nbsp;</div></div>
<div id="labelgroup_sendas"><span class="label">Send As:</span> <div class="value scrollable-list-short" id="detail_sendAsDelegates">&nbsp;</div></div>
<div id="labelgroup_sendonbehalf"><span class="label">Send on Behalf:</span> <div class="value scrollable-list-short" id="detail_sendOnBehalfDelegates">&nbsp;</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">&nbsp;</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">&nbsp;</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">&nbsp;</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">&nbsp;</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 &amp; 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 &amp; 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">&nbsp;</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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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