maik.ing | the terminal garden
Building, Breaking, and Automating.
Welcome to maik.ing. This is my digital toolbox for everything related to Microsoft 365, PowerShell automation, and cloud infrastructure. Here, I share functional scribbles, in-depth M365 insights, and the occasional Nordic breeze from my everyday IT life.
Subscribe via RSS here

🗂️ Microsoft Forms – Admin Guide: Form Ownership Transfer

Source: Microsoft Learn – Admin information for Microsoft Forms


📋 Overview

When an employee leaves your organization, their Microsoft Forms don't automatically transfer to someone else. This article explains how administrators can take ownership of those forms using a dedicated delegation URL — and what requirements and limitations apply.


🔐 Who Can Transfer Form Ownership?

Only the following roles can perform a form ownership transfer:

  • Global Administrator
  • Office Application Administrator (with a valid Microsoft Forms license)

⚠️ Regular users and standard admins cannot perform this action.


✅ Requirements Before You Transfer

All of the following conditions must be met before a transfer is possible:

#

Requirement

1️⃣

You are the Office Application Administrator with a valid Forms license

2️⃣

The employee's account has been deleted or disabled in Microsoft Entra ID (Azure AD)

3️⃣

(If account was deleted) The transfer happens within 30 days of account deletion

💡 Note: There is no time restriction for transferring forms from a disabled (but not deleted) account.


🔗 The Delegation URL

Microsoft provides a special URL to access the forms of a departed employee:

https://forms.office.com/Pages/delegatepage.aspx?originalowner=[email address]

🔄 How to Use It

  1. Open your browser's address bar
  2. Replace [email address] with the email address of the former form owner
  3. Example:
    https://forms.office.com/Pages/delegatepage.aspx?originalowner=JasonFabian@contoso.com
  4. You will now see all forms belonging to that user
  5. On the form you want to transfer, click More form actions (⋯)Move

💡 Tip: If the email address doesn't return results, try substituting the user's Object ID in place of the email address in the URL.


🔎 How to Check If an Account Was "Hard Deleted"

Before attempting a transfer, verify the account status via Microsoft Graph:

  1. Go to Microsoft Graph Explorer
  2. Run the following query (replace *user email* with the actual address):
    https://graph.microsoft.com/v1.0/directory/deletedItems/microsoft.graph.user?$filter=mail eq '*user email*'

Query Result

Meaning

Transfer Possible?

✅ Account info returned

Soft deleted, within 30-day window

✅ Yes

❌ No result

Either still active, or deleted > 30 days ago

⚠️ Depends

❌ No result + >30 days since deletion

Hard deleted

❌ No — forms are unrecoverable


❌ Common Error Messages & Solutions

Error Message

Cause

Solution

"We can't access this page – The form's owner still has an active account."

Owner has an active Forms license and account

Wait until the account is disabled or deleted

"We can't access this page – Make sure you've entered the email address correctly and the forms owner account wasn't deleted more than 30 days ago."

Wrong email or account deleted > 30 days ago

Verify the email; check deletion date

"We can't access this page – Make sure you've entered the email address correctly, and then try again."

Email is missing or misspelled

Double-check the URL


🏢 Transferring to a Group (Active Employees)

If you want to transfer ownership to a currently active employee, you can move the form to a group they belong to.

⚠️ Important: You must be a member of that group to perform the transfer. You may join the group, complete the transfer, and then leave the group afterward.


⚠️ Known Limitations (Microsoft Defender for Cloud Apps)

If your organization uses Microsoft Defender for Cloud Apps, the following scenarios may not work correctly:

  • 🔴 Template / form duplication — user may get stuck on a loading screen
  • 🔴 Form ownership transfer (user → group) on the delegate page — user may be blocked
  • 🔴 Admin phishing form unblock on the Admin review page — user may be blocked

📌 Quick Reference Summary

┌─────────────────────────────────────────────────────────────┐
│ TRANSFER CHECKLIST │
│ │
│ □ You are a Global Admin or Office App Admin │
│ □ Former employee's account is disabled or deleted │
│ □ If deleted: transfer within 30 days │
│ □ Use delegation URL with correct email / Object ID │
│ □ Move the form via "More form actions" → Move │
└─────────────────────────────────────────────────────────────┘

🔗 Related Links


📅 Last reviewed: May 2026 | Based on Microsoft documentation last updated: 2024-05-31

Bulk User Assignment to Exchange Online Groups via PowerShell

When a batch of new team members needs access to multiple Microsoft 365 or Distribution Groups, the admin portal becomes the wrong tool immediately. Here is how to automate it cleanly — with type detection, error handling, and a CSV audit trail.


The Scenario

You have a list of users and a list of group Object IDs. Every user needs to be a member of every group. Doing this manually through the Microsoft 365 Admin Center means navigating to each group, opening the members panel, searching for each user, and confirming — multiplied by the number of users times the number of groups.

For 15 users and 3 groups, that is 45 manual operations. Automate it instead.


The Challenge: Two Group Types, Two Cmdlets

Exchange Online uses completely different cmdlets depending on the group type:

Group Type

Add Member Cmdlet

Microsoft 365 Group

Add-UnifiedGroupLinks

Distribution Group / Mail-Enabled Security Group

Add-DistributionGroupMember

Passing a GUID to the wrong cmdlet fails silently or throws an unhelpful error. The script handles this automatically by probing the group type first and branching accordingly.

Use GUIDs as identifiers, not display names. Display names are user-editable and can cause silent mismatches. Object IDs are immutable.


The Script

==PowerShell==

# ============================================================
# Add-UsersToGroups.ps1
# Adds a list of users to Exchange Online groups.
# Supports Microsoft 365 Groups and Distribution Groups.
# ============================================================ # Connect to Exchange Online
Write-Host "Connecting to Exchange Online..." -ForegroundColor Cyan
Connect-ExchangeOnline # ── Users ────────────────────────────────────────────────────
$Users = @(
"user1@yourdomain.com"
"user2@yourdomain.com"
"user3@yourdomain.com"
# Add remaining users here
) # ── Groups (Object IDs) ──────────────────────────────────────
$Groups = @(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"
) # ── Processing ───────────────────────────────────────────────
$Results = @() foreach ($GroupId in $Groups) {
Write-Host "`nProcessing group: $GroupId" -ForegroundColor Yellow # Detect group type
$Group = $null
try {
$Group = Get-UnifiedGroup -Identity $GroupId -ErrorAction Stop
$GroupType = "UnifiedGroup"
Write-Host " Type: Microsoft 365 Group | Name: $($Group.DisplayName)" -ForegroundColor Green
}
catch {
try {
$Group = Get-DistributionGroup -Identity $GroupId -ErrorAction Stop
$GroupType = "DistributionGroup"
Write-Host " Type: Distribution Group | Name: $($Group.DisplayName)" -ForegroundColor Green
}
catch {
Write-Warning " Group '$GroupId' not found — skipping."
$Results += [PSCustomObject]@{
Group = $GroupId
User = "N/A"
Status = "ERROR – Group not found"
}
continue
}
} foreach ($User in $Users) {
try {
if ($GroupType -eq "UnifiedGroup") {
Add-UnifiedGroupLinks -Identity $GroupId -LinkType Members -Links $User -ErrorAction Stop
}
else {
Add-DistributionGroupMember -Identity $GroupId -Member $User -ErrorAction Stop
} Write-Host " [OK] $User added" -ForegroundColor Green
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "Successfully added"
}
}
catch {
$ErrMsg = $_.Exception.Message if ($ErrMsg -match "already a member") {
Write-Host " [~] $User is already a member" -ForegroundColor DarkYellow
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "Already a member"
}
}
else {
Write-Warning " [ERROR] $User – $ErrMsg"
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "ERROR: $ErrMsg"
}
}
}
}
} # ── Summary ───────────────────────────────────────────────────
Write-Host "`n========== SUMMARY ==========" -ForegroundColor Cyan
$Results | Format-Table -AutoSize # Export results as CSV
$CsvPath = "$PSScriptRoot\Add-UsersToGroups_Results.csv"
$Results | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host "Results saved to: $CsvPath" -ForegroundColor Cyan Disconnect-ExchangeOnline -Confirm:$false
Write-Host "Disconnected." -ForegroundColor Cyan

How It Works

1. Group type detection The script tries Get-UnifiedGroup first. If that throws, it falls back to Get-DistributionGroup. The detected type is stored and used to select the correct add-member cmdlet for every user in that group.

2. Already a member handling Without this, every pre-existing membership throws a red error and pollutes the output. The script matches the exception message and re-classifies it as a non-fatal warning.

3. CSV audit trail After every run, a results file is written alongside the script. Three possible status values:

Status

Meaning

Successfully added

User was not a member and has been added

Already a member

User was already in the group — no change made

ERROR: ...

Something went wrong — full message appended


Prerequisites and Usage

Install the Exchange Online module if not already present:

==PowerShell==

Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser

Run the script:

==PowerShell==

.\Add-UsersToGroups.ps1

A browser authentication prompt will appear for Exchange Online. After sign-in, the script runs unattended and disconnects automatically.


15 users. 3 groups. 45 clicks saved. One script.

When "Getting Windows Ready" Becomes a Nightmare – and TrustedInstaller Is the Villain

Tags: hyper-v windows-server troubleshooting trustedinstaller windows-update


It started like a completely normal evening. A Windows Server 2016 VM sitting on Hyper-V, spinning away at the "Getting Windows Ready" screen – for almost two hours. What followed was a deep dive through checkpoints, corrupted VHDX chains, registry hives, and finally one single command that solved everything.

Here's the full story.


The Setup

  • Host: Hyper-V on Windows Server
  • Guest: Windows Server 2016 VM
  • Symptom: Stuck on "Getting Windows Ready" after a cumulative Windows Update – for nearly 2 hours

Phase 1 – The Obvious Steps

First instinct: wait it out. HDD activity can spike during large updates, especially on VMs with spinning disks or limited I/O. But after two hours with no progress, it was time to act.

A hard reset was attempted. Then another. Both times, Windows booted straight back into the same screen – a classic update loop.

The loop happens when Windows Update partially applies a patch, sets pending operations in the registry or filesystem, and then fails silently on every reboot. Windows keeps trying. Windows keeps failing. You keep staring at the same screen.


Phase 2 – Mounting the VHDX Directly

Since the VM was unreachable via RDP and WinRE wasn't triggering automatically, the plan was to mount the VHDX directly from the Hyper-V host and clean up the update cache manually.

Mount-VHD -Path "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c.vhdx" -ReadWrite

The target: rename the SoftwareDistribution folder to break the update loop.

ren E:\Windows\SoftwareDistribution SoftwareDistribution.old

✅ Done. VHDX dismounted. VM started.

Result: Still stuck on "Getting Windows Ready."


Phase 3 – The VHDX Chain Breaks

Here's where things got interesting – and slightly nerve-wracking.

It turned out the VM had an existing checkpoint (.avhdx differencing disk). By mounting the parent VHDX directly, the parent-child relationship between the base disk and the differencing disk was broken. Hyper-V tracks this relationship using internal identifiers, and mounting the parent externally caused an ID mismatch.

The error on next start:

The chain of virtual hard disks is corrupted. 
There is a mismatch in the identifiers of the parent virtual hard disk
and differencing disk.

Not great. But fixable.

The Fix: Set-VHD -IgnoreIdMismatch

Set-VHD `
-Path "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c_<GUID>.avhdx" `
-ParentPath "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c.vhdx" `
-IgnoreIdMismatch

This command rewrites only the metadata reference in the differencing disk – no data is moved, nothing is overwritten. It's the surgical fix for exactly this scenario.

⚠️ Lesson learned: Never mount a parent VHDX directly when the VM has active checkpoints. Always merge or delete checkpoints first – or use Mount-VHD on the differencing disk itself.

VM started again. Still "Getting Windows Ready." Back to square one, but at least the chain was intact.


Phase 4 – WinRE and the Registry Hunt

With SoftwareDistribution already renamed and the VHDX chain repaired, the next target was the registry. The goal: find and remove any pending operation flags that were keeping Windows in the loop.

WinRE was triggered using the 3x hard reset method – forcefully shutting down the VM three times during the boot sequence until Windows automatically drops into the recovery environment.

In the WinRE command prompt, the SYSTEM and SOFTWARE hives were loaded manually:

reg load HKLM\TEMPSOFT C:\Windows\System32\config\SOFTWARE

reg query "HKLM\TEMPSOFT\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"
reg query "HKLM\TEMPSOFT\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" reg unload HKLM\TEMPSOFT

Note: In WinRE, the Windows partition is often not C:\ – always verify with dir C:\Windows, dir D:\Windows, etc. first.

Neither key existed. No PendingFileRenameOperations either. The registry was clean.

The update loop had no obvious flag driving it. Something else was keeping Windows stuck.


Phase 5 – The Real Culprit: TrustedInstaller

Here's the twist nobody expects.

The VM was still reachable on the network during the "Getting Windows Ready" phase. A quick check revealed that TrustedInstaller.exe – the Windows Modules Installer, responsible for applying updates – was still running as a process and had simply hung. It wasn't crashing, it wasn't erroring out. It was just... stuck. And Windows was politely waiting for it to finish before proceeding with the boot.

The fix was one command, executed remotely from another machine:

taskkill /S 10.x.x.x /U Administrator /P ******** /IM trustedinstaller.exe

Windows immediately continued booting. Server came up clean.


Root Cause Summary

Stage

Finding

Initial symptom

TrustedInstaller.exe hung during update application

Why looping

Process still alive across reboots, Windows waited for it

SoftwareDistribution rename

Didn't help – process was the issue, not the cache

VHDX mount

Caused ID mismatch due to existing checkpoint

Registry check

No pending flags found

Actual fix

Remote taskkill of trustedinstaller.exe


Key Takeaways

1. Check network connectivity first If the VM is still reachable during "Getting Windows Ready", you have more options than you think. taskkill /S is a powerful remote tool.

2. Never mount a parent VHDX with active checkpoints Use Set-VHD -IgnoreIdMismatch to recover, but avoid it in the first place.

3. TrustedInstaller can hang silently No event log entry, no crash dump, no obvious sign – it just sits there. If you're stuck in a "Getting Windows Ready" loop with no registry flags and a clean SoftwareDistribution, check whether TrustedInstaller.exe is still alive.

4. Always back up before touching registry or VHDX

Copy-Item "vmname-disk-c.vhdx" "vmname-disk-c.vhdx.bak"
reg export "HKLM\TEMP\ControlSet001\Control\Session Manager" C:\backup.reg

Two hours of troubleshooting. One taskkill. Sometimes IT is exactly like that.

Recursively Get All Members of an Exchange Online Distribution List (Including Dynamic & M365 Groups)

Getting a complete, flat list of end-users from a massive, nested distribution list in Exchange Online can be surprisingly frustrating.

If you try to use the standard Get-DistributionGroupMember cmdlet, you will quickly notice two things:

  1. It lacks a native -Recursive parameter.
  2. If your main distribution list contains nested Microsoft 365 Groups (Unified Groups) or Dynamic Distribution Groups, a simple recursive loop will crash and flood your console with red ManagementObjectNotFoundException errors.

Different group types in Exchange Online require completely different cmdlets. Here is a robust PowerShell function that dynamically identifies the group type, uses the correct cmdlet to fetch its members, and gracefully skips broken or orphaned objects without crashing.

The PowerShell Script

Copy and paste this function into your Exchange Online PowerShell session:

PowerShell

Function Get-DistributionGroupMemberRecursive {
param (
[Parameter(Mandatory=$true)]
[string]$Identity,
[string]$GroupType = "DistributionGroup" # Default starting type
) $Members = $null try {
# 1. Select the correct cmdlet based on the group type
if ($GroupType -match "GroupMailbox|UnifiedGroup") {
# For Microsoft 365 Groups
$Members = Get-UnifiedGroupLinks -Identity $Identity -LinkType Members -ErrorAction Stop
}
elseif ($GroupType -match "DynamicDistributionGroup") {
# For Dynamic Distribution Groups
$Members = Get-DynamicDistributionGroupMember -Identity $Identity -ErrorAction Stop
}
else {
# For Classic Distribution Lists / Security Groups
$Members = Get-DistributionGroupMember -Identity $Identity -ResultSize Unlimited -ErrorAction Stop
}
}
catch {
# Gracefully handle orphaned objects or resolution errors
Write-Warning "Failed to read group '$Identity' ($GroupType). It may be an orphaned object."
return
} # 2. Iterate through members and evaluate
if ($null -ne $Members) {
foreach ($Member in $Members) {

$MemberType = $Member.RecipientTypeDetails # Check if the member is a nested group and recurse accordingly
if ($MemberType -match "GroupMailbox|UnifiedGroup") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "GroupMailbox"
}
elseif ($MemberType -match "DynamicDistributionGroup") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "DynamicDistributionGroup"
}
elseif ($MemberType -match "Group") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "DistributionGroup"
}
else {
# Output the actual end-user (UserMailbox, SharedMailbox, MailContact, etc.)
$Member | Select-Object Name, PrimarySmtpAddress, RecipientTypeDetails
}
}
}
}

Why This Approach Works

  • Smart Cmdlet Switching: The script checks the RecipientTypeDetails of every nested group. It seamlessly switches to Get-UnifiedGroupLinks for M365 Groups and Get-DynamicDistributionGroupMember when it needs to calculate dynamic rule-based memberships.
  • Error Handling: Real-world Active Directories are messy. The try/catch block ensures that if a nested group contains a deleted user or an orphaned SID, the script throws a gentle yellow warning instead of a fatal error, allowing the rest of the extraction to continue.

How to Use and Export

Once the function is loaded into your session, you can run it against your parent group. To make the output useful, you can pipe the results directly into a CSV file.

Note: Because users might be members of multiple nested groups, adding Select-Object -Unique is highly recommended to filter out duplicates.

PowerShell

# Run the script and export a clean, deduplicated list to a CSV file
Get-DistributionGroupMemberRecursive -Identity "company.news@yourdomain.com" |
Select-Object Name, PrimarySmtpAddress, RecipientTypeDetails -Unique |
Export-Csv -Path "C:\temp\CompanyNews_Members.csv" -NoTypeInformation -Encoding UTF8

🛠️ Exchange Online: The "Disabled" Flag Bug After Mailbox Conversion

Have you recently converted a Shared Mailbox to a Regular User Mailbox, assigned a license, and yet OWA keeps crashing on login?

Error: AccountTerminationException | st: 440

Symptom: SyntaxError: JSON.parse: unexpected end of data

Even though the Microsoft 365 Admin Center shows everything as "Healthy," the mailbox is stuck in a ghost state. Here is how to fix the Disabled Flag Bug using PowerShell.


The Core Problem: Why Login Fails

By design, Shared Mailboxes are user accounts where direct login is disabled. When you convert a mailbox to Regular, Exchange updates the mailbox type but often "forgets" to flip the sign-in flag in the underlying Microsoft Entra (Azure AD) identity.

The result: The mailbox exists and the license is active, but the server kills the authentication mid-stream because it still thinks the user isn't allowed to log in.


The Solution: The PowerShell Fix

To resolve this, we must manually force the AccountDisabled attribute to False.

1. Verify the Status

Connect to Exchange Online and check what the system actually thinks of the account:

PowerShell

Get-User -Identity "info@yourdomain.com" | Select-Object Name, RecipientTypeDetails, AccountDisabled

If AccountDisabled returns True, you’ve found your culprit.

2. Force the Login to Enable

Run the following command to lift the restriction:

PowerShell

Set-Mailbox -Identity "info@yourdomain.com" -AccountDisabled $false

3. Sync the Identity via Microsoft Graph

If OWA still throws the 440 error after the command above, the user object itself must be enabled in the Microsoft 365 directory:

PowerShell

# Requires the Microsoft.Graph module
Update-MgUser -UserId "info@yourdomain.com" -AccountEnabled $true

Troubleshooting Checklist

If your conversion is still stuck, work through this list:

  1. Licensing: Is an Exchange Online license (Plan 1/2 or Business) actually assigned?
  2. Password: Was a new password set after the conversion?
  3. AccountEnabled: Is the flag set to $true (or AccountDisabled $false) via PowerShell?
  4. Browser Cache: Test in Incognito Mode. This is critical. OWA aggressively caches "Shared Mailbox" session tokens, which will trigger the JSON error even if the backend is fixed.

Written for admins who don't have time to wait 24 hours for "Replication."

🚀 Scripting Bulk Mailbox Delegation in Exchange Online

When managing a growing team, you often find yourself in a situation where a lead user (e.g., a department head or administrator) needs access to multiple shared or functional mailboxes.

Manually clicking through the Microsoft 365 Admin Center for 10+ mailboxes is a recipe for boredom and typos. Here is how I solved this using the Exchange Online PowerShell V3 module.

The Scenario

We need to grant one primary user (User A) two specific types of permissions across a list of target mailboxes:

  1. FullAccess: The ability to open the mailbox, read, and organize emails.
  2. SendAs: The ability to send emails appearing as the target mailbox address.

The PowerShell Solution

First, ensure you are connected:

Connect-ExchangeOnline

Then, run this script to loop through your targets:

PowerShell

# 1. Define the Lead User (The one receiving the permissions)
$LeadUser = "primary.user@company-it.com" # 2. Define the list of target mailboxes (Shared or User mailboxes)
$TargetMailboxes = @(
"info@company-it.com",
"accounting@company-it.com",
"logistics@company-it.com",
"project-alpha@company-it.com",
"support@company-it.com",
"hr-dept@company-it.com"
) # 3. Apply permissions via loop
foreach ($Mailbox in $TargetMailboxes) {
Write-Host "Processing: $Mailbox" -ForegroundColor Cyan

# Grant Full Access (includes Auto-Mapping for Outlook Desktop)
Add-MailboxPermission -Identity $Mailbox -User $LeadUser -AccessRights FullAccess -InheritanceType All -Confirm:$false

# Grant Send As permissions
Add-RecipientPermission -Identity $Mailbox -Trustee $LeadUser -AccessRights SendAs -Confirm:$false
} Write-Host "Success: All permissions applied." -ForegroundColor Green
powered by Scribbles