# Version date: 2026-07-28 17:04 (CEST) <# Export local user profiles for migration to a new microscope computer. Every local user is read exactly once and sorted into ONE of two output files, so no user (and no password) appears in both. Filenames are tagged with the computer name and the $MonthsBack setting below (e.g. Users_Last6Months_Kellner.csv for the default of 6 months): - Users_LastMonths_.csv users who logged on within the last N months - Users_Other_.csv all remaining users (older / never logged on) Both files are written in a single run. By default, accounts that are members of the local Administrators group are excluded entirely. Set $MigrateAdmins = "Yes" below to include them. Run on the OLD computer, in an elevated ("Run as administrator") PowerShell. #> $MonthsBack = 6 $MigrateAdmins = "No" # set to "Yes" to include accounts that are members of the local Administrators group $CutoffDate = (Get-Date).AddMonths(-$MonthsBack) # By default, this script reads/writes its files in the same folder the # script itself is saved in - PowerShell's built-in $PSScriptRoot variable # always points there automatically, so nothing needs to be typed in # manually. To use a different, fixed folder instead (e.g. C:\Temp), replace # the line below with a fixed path, for example: # $OutputFolder = "C:\Temp" $OutputFolder = $PSScriptRoot $ComputerTag = $env:COMPUTERNAME $RecentOutputFile = Join-Path $OutputFolder "Users_Last$($MonthsBack)Months_$ComputerTag.csv" $OtherOutputFile = Join-Path $OutputFolder "Users_Other_$ComputerTag.csv" # Built-in Windows accounts to exclude $ExcludedUsers = @( "Administrator", "Guest", "Gast", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0" ) # -------------------------------------------------------------------------- # Temporary password generator # Format: StartPassword + 8 random characters (digits / letters / symbols) # Only characters directly typeable (base key or with Shift, no AltGr) on # German, US, and UK keyboard layouts are used. On a keyboard layout that # differs significantly from these three (e.g. French AZERTY), this list # may need to be edited by hand. # Visually ambiguous letters (O/0, l/I) are excluded for readability. # -------------------------------------------------------------------------- $PasswordChars = @(48..57 | ForEach-Object { [char]$_ }) + # 0-9 @(65..90 | ForEach-Object { [char]$_ } | Where-Object { $_ -notin 'O','I' }) + # A-Z (no O, I) @(97..122 | ForEach-Object { [char]$_ } | Where-Object { $_ -notin 'o','l' }) + # a-z (no o, l) @('!','?','%','&','/','(',')','=','-','_','.',',',';',':','+','*') # symbols (DE/US/UK keyboards) function New-TempPassword { $suffix = -join (1..8 | ForEach-Object { $PasswordChars | Get-Random }) return "StartPassword$suffix" } # Make sure the output folder exists (only relevant if $OutputFolder above # was changed to a custom path that doesn't exist yet - $PSScriptRoot itself # always already exists, since the script is running from there) New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null # -------------------------------------------------------------------------- # Query local user profiles (with error handling) # -------------------------------------------------------------------------- try { $Profiles = Get-CimInstance Win32_UserProfile -ErrorAction Stop } catch { Write-Host "ERROR: Could not query Win32_UserProfile via CIM: $($_.Exception.Message)" exit 1 } # These two lists will collect the users as they are sorted, one entry each. # They start empty and are filled by the loop below. $RecentUsers = @() $OtherUsers = @() # -------------------------------------------------------------------------- # Determine members of the local Administrators group (language-independent, # resolved via well-known SID so this also works on non-English Windows). # Note: we deliberately do NOT filter by ObjectClass here (e.g. "User" vs. # nested "Group" members) because that property is localized by Windows # (e.g. "Benutzer" on German systems) and would silently break the filter # on non-English machines. Any nested group name that ends up in this list # simply won't match a real Windows username later on, so it's harmless. # -------------------------------------------------------------------------- try { $AdminGroupSID = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544") $AdminGroupName = ($AdminGroupSID.Translate([System.Security.Principal.NTAccount])).Value.Split('\')[1] $AdminUsers = @( Get-LocalGroupMember -Group $AdminGroupName -ErrorAction Stop | ForEach-Object { $_.Name.Split('\')[-1] } ) } catch { Write-Host "ERROR: Could not determine members of the local Administrators group: $($_.Exception.Message)" exit 1 } # -------------------------------------------------------------------------- # Go through every Windows user profile found on this computer, one by one, # and decide for each one: keep it or skip it, and if kept, which of the # two output lists (recent / other) it belongs to. # -------------------------------------------------------------------------- foreach ($UserProfile in $Profiles) { # Skip system profiles if ($UserProfile.Special) { continue } # Only process profiles located under C:\Users if ($UserProfile.LocalPath -notlike "C:\Users\*") { continue } # The folder name under C:\Users IS the Windows login name, e.g. # C:\Users\jsmith -> jsmith $UserName = Split-Path $UserProfile.LocalPath -Leaf # Skip built-in Windows accounts (see $ExcludedUsers list above) if ($UserName -in $ExcludedUsers) { continue } # Skip local admin accounts unless explicitly included # In other words: If $MigrateAdmins is not set to Yes, and this user is in the admin list, then drop this user. if ($MigrateAdmins -ine "Yes" -and $UserName -in $AdminUsers) { continue } $User = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue if ($null -eq $User) { continue } # Use the account name if FullName is empty if ([string]::IsNullOrWhiteSpace($User.FullName)) { $FullName = $User.Name } else { $FullName = $User.FullName } # Build one row for the output CSV: name, display name, a fresh # temporary password, and when the user last actually logged on. This # uses Get-LocalUser's LastLogon (the same data "net user" shows, # updated only on genuine authentication) rather than # Win32_UserProfile.LastUseTime, which can also be updated by # background processes (antivirus scans, management tools, etc.) # touching a user's registry hive without them actually logging in - # which would otherwise make a dormant account look recently active. $Entry = [PSCustomObject]@{ Name = $User.Name FullName = $FullName Password = New-TempPassword LastLogon = $User.LastLogon } # Sort into exactly one of the two lists - never both. A $null # LastLogon (account exists but has never actually been logged into) # correctly falls into "Other" here, not "Recent". if ($User.LastLogon -and $User.LastLogon -ge $CutoffDate) { $RecentUsers += $Entry } else { $OtherUsers += $Entry } } # Write the two lists to their CSV files. Sorted newest-login-first. $RecentUsers | Sort-Object LastLogon -Descending | Export-Csv $RecentOutputFile -NoTypeInformation -Encoding UTF8 $OtherUsers | Sort-Object LastLogon -Descending | Export-Csv $OtherOutputFile -NoTypeInformation -Encoding UTF8 # -------------------------------------------------------------------------- # Combine the two files just written into one overview file, containing # every user exactly once: the "last N months" file first, then "Other". # -------------------------------------------------------------------------- $AllOutputFile = Join-Path $OutputFolder "Users_All_$ComputerTag.csv" $CombinedUsers = @() $CombinedUsers += Import-Csv $RecentOutputFile $CombinedUsers += Import-Csv $OtherOutputFile $CombinedUsers | Export-Csv $AllOutputFile -NoTypeInformation -Encoding UTF8 Write-Host "" Write-Host "$($RecentUsers.Count) users (last $MonthsBack months) exported to $RecentOutputFile" Write-Host "$($OtherUsers.Count) users (other) exported to $OtherOutputFile" Write-Host "$($CombinedUsers.Count) users (combined) exported to $AllOutputFile"