Public/get-allusers.ps1

# get-allusers.ps1
# All users in tenancy — Display Name, UPN, Licenses, Last Mailbox Activity, Notes
# No Entra P1/P2 required. Uses Exchange mailbox stats for last activity.
# Requires: Graph (User.Read.All, Directory.Read.All) + Exchange Online

if (-not (Get-ConnectionInformation)) { Connect-ExchangeOnline }
if (-not (Get-MgContext)) {
    Connect-MgGraph -Scopes "User.Read.All", "Directory.Read.All" -ContextScope Process
}

Write-Host "Fetching SKU list..."
$skus = Get-MgSubscribedSku
$skuLookup = @{}
foreach ($sku in $skus) { $skuLookup[$sku.SkuId] = $sku.SkuPartNumber }

Write-Host "Fetching users..."
$users = Get-MgUser -All -Property "DisplayName,UserPrincipalName,AssignedLicenses,AccountEnabled"

Write-Host "Fetching mailbox activity (takes a moment)..."
$mailboxStats = Get-MailboxStatistics -ResultSize Unlimited | Select-Object DisplayName, UserPrincipalName, LastLogonTime

$statsIndex = @{}
foreach ($stat in $mailboxStats) {
    if ($stat.UserPrincipalName) { $statsIndex[$stat.UserPrincipalName.ToLower()] = $stat.LastLogonTime }
}

$results = foreach ($user in $users) {
    $licenses = if ($user.AssignedLicenses.Count -gt 0) {
        ($user.AssignedLicenses.SkuId | ForEach-Object { $skuLookup[$_] }) -join ", "
    } else { "None" }

    # Null-UPN guard — mirrors the same check used when building $statsIndex
    # above. Without this guard, a single orphan or partially-provisioned
    # account (no UPN assigned) throws NullReferenceException on .ToLower()
    # and aborts the entire export, taking the report down for the whole
    # tenant. Orphans are rare but real (failed provisioning, half-deleted
    # directory objects) — surfacing them in the export is exactly the point
    # of this report, so we deliberately do NOT skip them.
    if ($user.UserPrincipalName) {
        $lastLogin = $statsIndex[$user.UserPrincipalName.ToLower()]
    } else {
        # Orphan path: no UPN means no mailbox-stats lookup is possible.
        # Leave $lastLogin null and let the "No UPN" note below take
        # precedence over the usual Disabled / No Activity / Active notes.
        $lastLogin = $null
    }
    $enabled   = $user.AccountEnabled

    # Note ordering matters here — the orphan check comes first so that a
    # user with no UPN is never mis-labelled as "No Mailbox Activity" (which
    # would be technically true but uselessly vague).
    $notes = if (-not $user.UserPrincipalName) {
        "No UPN — orphan account"
    } elseif (-not $enabled) {
        "Account Disabled"
    } elseif (-not $lastLogin) {
        "No Mailbox Activity"
    } else {
        "Active"
    }

    [PSCustomObject]@{
        "Display Name" = $user.DisplayName
        "UPN"          = $user.UserPrincipalName
        "Enabled"      = $enabled
        "Licenses"     = $licenses
        "Last Login"   = $lastLogin
        "Notes"        = $notes
    }
}

$results | Sort-Object "Last Login" | Format-Table -AutoSize

$path = "$env:USERPROFILE\Desktop\AllUsers_$(Get-Date -Format 'yyyyMMdd').csv"
$results | Export-Csv -Path $path -NoTypeInformation
Write-Host "`nExported to $path"