{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Jarrett Williams",
  "home_page_url": "https://jarrettwilliams.com/",
  "feed_url": "https://jarrettwilliams.com/feed.json",
  "description": "Practical notes on IT operations, systems engineering, Azure, automation, networking, and datacenter work.",
  "language": "en-US",
  "authors": [
    {
      "name": "Jarrett Williams",
      "url": "https://jarrettwilliams.com/"
    }
  ],
  "items": [
    {
      "id": "https://jarrettwilliams.com/blog/publishing-a-static-site-to-cloudflare-pages/",
      "url": "https://jarrettwilliams.com/blog/publishing-a-static-site-to-cloudflare-pages/",
      "title": "Publishing a Static Site to Cloudflare Pages Without Making It a Whole Project",
      "content_html": "<p>I like static sites because they are simple in all the ways that usually matter later.</p>\n<p>There is no app server to babysit, no database to drag along, and no pile of moving parts that turns a small website into an ongoing maintenance project. If all you need is a blog, portfolio, or personal site, that simplicity is hard to beat.</p>\n<p>Cloudflare Pages is a good fit for that kind of setup.</p>\n<h2 id=\"what-cloudflare-pages-actually-does\"><a class=\"heading-anchor\" href=\"#what-cloudflare-pages-actually-does\" aria-label=\"Link to this section\">#</a>What Cloudflare Pages actually does</h2>\n<p>At the basic level, Cloudflare Pages hosts your built site and puts it behind Cloudflare&#39;s network. You give it the finished output, and it serves the site over HTTPS on a fast edge platform.</p>\n<p>That is really the whole pitch.</p>\n<p>If your project builds into a folder like <code>dist</code>, <code>public</code>, or <code>out</code>, Cloudflare Pages can take that output and publish it. You do not need a full application runtime just to serve a few pages.</p>\n<h2 id=\"why-static-is-nice-for-smaller-sites\"><a class=\"heading-anchor\" href=\"#why-static-is-nice-for-smaller-sites\" aria-label=\"Link to this section\">#</a>Why static is nice for smaller sites</h2>\n<p>The biggest benefit is not that it is trendy and not even that it is fast, although it usually is.</p>\n<p>The biggest benefit is that it keeps the problem small.</p>\n<p>If a page looks wrong, you check the generated files. If the domain is wrong, you check DNS. If the deployment fails, you look at the build or the upload step. That is a much easier troubleshooting path than chasing problems through a server process, framework config, database, and hosting panel at the same time.</p>\n<p>For a personal site, that matters a lot.</p>\n<h2 id=\"the-basic-workflow\"><a class=\"heading-anchor\" href=\"#the-basic-workflow\" aria-label=\"Link to this section\">#</a>The basic workflow</h2>\n<p>Most static-site deployments to Cloudflare Pages follow the same rough pattern:</p>\n<ol><li>Write or update the content.</li><li>Run the build.</li><li>Make sure the output folder looks right.</li><li>Publish that output to Cloudflare Pages.</li><li>Attach the custom domain and verify the live site.</li></ol>\n<p>That may sound obvious, but it helps to keep those steps mentally separate. A lot of deployment confusion comes from mixing them together.</p>\n<figure>\n  <img src=\"/assets/images/cloudflare-pages-workflow.svg\" alt=\"A simple Cloudflare Pages workflow showing build, deploy, and domain verification\" width=\"1200\" height=\"680\" loading=\"lazy\" decoding=\"async\" />\n  <figcaption>A simple way to think about it: build the site, publish the output, then verify the actual domain.</figcaption>\n</figure>\n<h2 id=\"what-build-means-in-plain-english\"><a class=\"heading-anchor\" href=\"#what-build-means-in-plain-english\" aria-label=\"Link to this section\">#</a>What &quot;build&quot; means in plain English</h2>\n<p>When people talk about building a static site, it can sound more complicated than it really is.</p>\n<p>In practice, the build step just takes your source files and turns them into browser-ready pages.</p>\n<p>That might mean:</p>\n<ul><li>markdown gets converted into HTML</li><li>templates get filled in</li><li>CSS gets copied or bundled</li><li>static assets get placed in the right folders</li></ul>\n<p>When the build is done, you are left with a plain folder of files that a browser can read directly.</p>\n<p>That is the part I care about most before publishing. If the output is clean, the deploy is usually straightforward.</p>\n<h2 id=\"a-simple-example\"><a class=\"heading-anchor\" href=\"#a-simple-example\" aria-label=\"Link to this section\">#</a>A simple example</h2>\n<p>For a lightweight project, the build may be as simple as:</p>\n<div class=\"code-block\"><span class=\"code-lang\">bash</span><pre><code class=\"language-shell\">npm install\nnpm run build</code></pre></div>\n<p>After that, you may end up with something like:</p>\n<div class=\"code-block\"><span class=\"code-lang\">text</span><pre><code>dist/\n  index.html\n  about/index.html\n  blog/index.html\n  assets/styles.css</code></pre></div>\n<p>That <code>dist</code> folder is the website.</p>\n<p>Everything outside that folder is source code, content, and build logic. Useful for you, but not the thing visitors actually need.</p>\n<h2 id=\"two-ways-to-deploy-it\"><a class=\"heading-anchor\" href=\"#two-ways-to-deploy-it\" aria-label=\"Link to this section\">#</a>Two ways to deploy it</h2>\n<p>There are two common ways to publish a static site to Cloudflare Pages.</p>\n<p>The first is to connect a Git repository. In that setup, Cloudflare watches a branch, runs your build command, and publishes the output automatically when you push changes.</p>\n<p>The second is direct upload. In that setup, you run the build yourself and upload the finished output folder using a tool like Wrangler.</p>\n<p>Both are valid.</p>\n<p>If the repo and build process are stable, the Git-connected route is convenient. If you want more control, or you are working with a custom setup and want to verify the build before it goes live, direct upload is often the cleaner option.</p>\n<h2 id=\"what-actually-gets-pushed\"><a class=\"heading-anchor\" href=\"#what-actually-gets-pushed\" aria-label=\"Link to this section\">#</a>What actually gets pushed</h2>\n<p>This is the part that clears up a lot of confusion.</p>\n<p>Cloudflare Pages does not need your entire project in order to serve a static site. It needs the built output.</p>\n<p>If you use Git integration, Cloudflare pulls the repo and builds it on its side. If you use direct deployment, you build the site locally and upload the output folder yourself.</p>\n<p>Either way, the important question is the same:</p>\n<p>Is the final output correct?</p>\n<p>That is what people are going to hit in the browser.</p>\n<h2 id=\"a-direct-deploy-example\"><a class=\"heading-anchor\" href=\"#a-direct-deploy-example\" aria-label=\"Link to this section\">#</a>A direct-deploy example</h2>\n<p>If you want to keep things simple and explicit, a direct deploy is easy to follow:</p>\n<div class=\"code-block\"><span class=\"code-lang\">bash</span><pre><code class=\"language-shell\">npm install\nnpm run build\nnpx wrangler pages deploy dist <span class=\"tok-flag\">--project-name</span> your-project-name</code></pre></div>\n<p>That sequence does three things:</p>\n<ol><li>installs the project dependencies</li><li>builds the static site</li><li>uploads the built output to Cloudflare Pages</li></ol>\n<p>I like this flow because it leaves very little mystery about what is happening.</p>\n<p>You can stop after <code>npm run build</code>, open the <code>dist</code> folder, and confirm the pages are there before you publish anything.</p>\n<h2 id=\"where-people-usually-get-tripped-up\"><a class=\"heading-anchor\" href=\"#where-people-usually-get-tripped-up\" aria-label=\"Link to this section\">#</a>Where people usually get tripped up</h2>\n<p>One common mistake is treating a static site like a server app.</p>\n<p>If the site is just HTML, CSS, images, and a little JavaScript, do not make deployment harder than it needs to be. Build it, publish it, and keep production simple.</p>\n<p>Another common mistake is assuming the site is broken when the real issue is the domain cutover.</p>\n<p>Sometimes the build is fine and the upload is fine, but DNS still points somewhere old. Sometimes the browser is just serving cached content. Sometimes the wrong host is still responding.</p>\n<p>When that happens, I usually check things in this order:</p>\n<ol><li>Did the site build successfully?</li><li>Did the deployment actually finish?</li><li>Does the Pages preview URL look right?</li><li>Does the custom domain point at the correct Pages project?</li><li>Is the browser showing something stale?</li></ol>\n<p>That order saves a lot of time.</p>\n<h2 id=\"the-domain-part\"><a class=\"heading-anchor\" href=\"#the-domain-part\" aria-label=\"Link to this section\">#</a>The domain part</h2>\n<p>Once the Pages project is working on its preview or project URL, the rest is mostly DNS and verification.</p>\n<p>You add the custom domain in Cloudflare Pages, update the DNS records if needed, and wait for the domain to verify. After that, I like to check both the project URL and the production domain to make sure they are serving the same site.</p>\n<p>If the old homepage is still showing up, I do not immediately start rewriting code. I check whether the domain is really pointed at the new deployment first.</p>\n<h2 id=\"why-i-still-like-this-setup\"><a class=\"heading-anchor\" href=\"#why-i-still-like-this-setup\" aria-label=\"Link to this section\">#</a>Why I still like this setup</h2>\n<p>Years from now, I would much rather come back to a small repo with markdown posts, a build script, and a predictable hosting target than try to remember why a personal site needed an overcomplicated stack.</p>\n<p>That is the real appeal here.</p>\n<p>For the right kind of site, static output plus Cloudflare Pages keeps things fast, cheap, and pretty easy to manage. It is not the answer for every project, but for a straightforward blog or personal site, it is a solid option that does not create extra work for no reason.</p>",
      "summary": "A practical walkthrough for getting a simple static site onto Cloudflare Pages without turning deployment into a second job.",
      "date_published": "2026-04-28T00:00:00.000Z",
      "tags": [
        "Cloudflare",
        "Static Site",
        "DevOps"
      ],
      "image": "https://jarrettwilliams.com/assets/images/social-card-publishing-a-static-site-to-cloudflare-pages.f0338a4b.png"
    },
    {
      "id": "https://jarrettwilliams.com/blog/remove-empty-okta-groups/",
      "url": "https://jarrettwilliams.com/blog/remove-empty-okta-groups/",
      "title": "How to remove empty groups in OKTA manually or via API?",
      "content_html": "<h2>How do I remove empty groups in OKTA manually?</h2>\n<p>Let's say we have a case of an app import for an org-to-org that imported groups accidentally. Now that those groups from the other tenant are in your tenant, it is extremely annoying when searching as these groups will show as zero users. Moreso, the only way to \"remove\" these groups is manually, one by one.</p>\n<p>What if I told you there was a way to do this via python and the OKTA API?</p>\n<h3>Manual Approach (Admin Console)</h3>\n<ol>\n  <li><strong>Navigate to Groups:</strong> Log in to your Okta Admin Console, go to Directory > Groups.</li>\n  <li><strong>Filter for Empty Groups:</strong> Okta doesn't have a direct filter for groups with zero members, but you can sort or search manually. Look at the \"Members\" column - groups showing \"0\" are your targets.</li>\n  <li><strong>Delete Empty Groups:</strong> Click on a group with zero members, then hit the More Actions dropdown (or similar option depending on your interface) and select Delete Group. Confirm the deletion. Repeat for each empty group.</li>\n</ol>\n<p>This works fine if you only have a handful of groups to check, but it's tedious for a large organization.</p>\n<h3>Automated Approach (Okta API)</h3>\n<p>For a faster, scalable solution - especially if you have many groups - use the Okta API:</p>\n<ol>\n  <li><strong>Get an API Token:</strong> In the Admin Console, go to Security > API > Tokens, and create a token with appropriate permissions.</li>\n  <li><strong>List All Groups:</strong> Use the API endpoint GET /api/v1/groups to fetch all groups in your Okta org. This returns a JSON list with group details, including member counts.</li>\n  <li><strong>Identify Empty Groups:</strong> Parse the response. Each group object includes a _embedded section with stats (if requested via expand=stats in the query). Check \"membersCount\": 0 to find empty ones.</li>\n  <li><strong>Delete Empty Groups:</strong> For each group with zero members, grab its id and send a DELETE /api/v1/groups/{groupId} request. You'll need to authenticate with your API token.</li>\n</ol>\n<p>Here's a more robust Python script that handles pagination, error checking, and provides more detailed output:</p>\n<div class=\"code-block\"><span class=\"code-lang\">python</span><pre><code>import requests\n\n# Replace these with your actual Okta details\nAPI_TOKEN = &quot;your-api-token-here&quot;  # Your Okta API token\nORG_URL = &quot;https://your-domain.okta.com&quot;  # Your Okta domain\n\n# Set up headers for API requests\nHEADERS = {\n    &quot;Authorization&quot;: f&quot;SSWS {API_TOKEN}&quot;,\n    &quot;Accept&quot;: &quot;application/json&quot;\n}\n\ndef get_all_groups():\n    &quot;&quot;&quot;Fetch all groups with pagination.&quot;&quot;&quot;\n    groups = []\n    url = f&quot;{ORG_URL}/api/v1/groups?expand=stats&quot;\n    while url:\n        try:\n            response = requests.get(url, headers=HEADERS)\n            response.raise_for_status()\n            data = response.json()\n            if isinstance(data, list):\n                groups.extend(data)\n            else:\n                print(f&quot;Unexpected response: {data}&quot;)\n                break\n            url = response.links.get(&quot;next&quot;, {}).get(&quot;url&quot;)\n        except requests.exceptions.RequestException as error:\n            print(f&quot;Error fetching groups: {error}&quot;)\n            break\n    return groups\n\ndef get_member_count(group_id):\n    &quot;&quot;&quot;Fallback: Check member count by fetching users in the group.&quot;&quot;&quot;\n    try:\n        response = requests.get(f&quot;{ORG_URL}/api/v1/groups/{group_id}/users&quot;, headers=HEADERS)\n        response.raise_for_status()\n        return len(response.json())\n    except requests.exceptions.RequestException as error:\n        print(f&quot;Error checking members for group {group_id}: {error}&quot;)\n        return None\n\ndef delete_empty_groups():\n    &quot;&quot;&quot;Delete groups with zero members.&quot;&quot;&quot;\n    groups = get_all_groups()\n    if not groups:\n        print(&quot;No groups found or error occurred.&quot;)\n        return\n\n    for group in groups:\n        if &quot;id&quot; not in group or &quot;profile&quot; not in group or &quot;name&quot; not in group[&quot;profile&quot;]:\n            print(f&quot;Skipping malformed group: {group}&quot;)\n            continue\n\n        group_id = group[&quot;id&quot;]\n        group_name = group[&quot;profile&quot;][&quot;name&quot;]\n\n        if &quot;_embedded&quot; in group and &quot;stats&quot; in group[&quot;_embedded&quot;]:\n            member_count = group[&quot;_embedded&quot;][&quot;stats&quot;].get(&quot;usersCount&quot;, -1)\n        else:\n            print(f&quot;Stats unavailable for {group_name}, checking members directly...&quot;)\n            member_count = get_member_count(group_id)\n\n        if member_count is None:\n            print(f&quot;Skipping {group_name} due to error.&quot;)\n            continue\n\n        if member_count == 0:\n            try:\n                delete_response = requests.delete(f&quot;{ORG_URL}/api/v1/groups/{group_id}&quot;, headers=HEADERS)\n                if delete_response.status_code == 204:\n                    print(f&quot;Deleted empty group: {group_name} (ID: {group_id})&quot;)\n                else:\n                    print(f&quot;Failed to delete {group_name}: {delete_response.status_code} - {delete_response.text}&quot;)\n            except requests.exceptions.RequestException as error:\n                print(f&quot;Error deleting {group_name}: {error}&quot;)\n        else:\n            print(f&quot;Skipping {group_name} - has {member_count} members&quot;)\n\nif __name__ == &quot;__main__&quot;:\n    print(&quot;Starting Okta group cleanup...&quot;)\n    delete_empty_groups()\n    print(&quot;Cleanup complete.&quot;)</code></pre></div>\n<h3>Things to Watch Out For</h3>\n<ul>\n  <li><strong>Permissions:</strong> Ensure your API token or admin account has rights to delete groups.</li>\n  <li><strong>Linked Groups:</strong> If a group is tied to an app or rule, Okta might block deletion. Unassign it first under Applications or Group Rules.</li>\n  <li><strong>Rate Limits:</strong> The API has limits - pace your requests if you're deleting in bulk.</li>\n</ul>\n<p>The API method is your best bet for efficiency. If scripting isn't your thing, you could also export the group list via a tool like Okta Workflows or a third-party integration, filter for zero-member groups in a spreadsheet, and delete them manually or via API calls. Either way, you'll have a cleaner Okta setup by the end of it.</p>",
      "summary": "A guide on removing empty groups in OKTA using both manual and automated approaches.",
      "date_published": "2025-02-25T00:00:00.000Z",
      "tags": [
        "Okta",
        "Identity",
        "Automation"
      ],
      "image": "https://jarrettwilliams.com/assets/images/social-card-remove-empty-okta-groups.e9ddff25.png"
    },
    {
      "id": "https://jarrettwilliams.com/blog/datacenter-technician-tools/",
      "url": "https://jarrettwilliams.com/blog/datacenter-technician-tools/",
      "title": "Essential Tools for a Datacenter Technician's Visit",
      "content_html": "<p>When a datacenter technician prepares for a site visit, having the right tools can make all the difference. Whether you're curious about what goes on behind the scenes in these digital powerhouses or considering a career in datacenter management, understanding the essential tools is a great place to start. Let's explore what a datacenter technician might pack for their next visit.</p>\n<h2 id=\"1-laptop-and-diagnostic-software\"><a class=\"heading-anchor\" href=\"#1-laptop-and-diagnostic-software\" aria-label=\"Link to this section\">#</a>1. Laptop and Diagnostic Software</h2>\n<p>A technician's most crucial tool is often their laptop, loaded with diagnostic software and remote management tools. This allows them to interface with servers, run tests, and access documentation on the go. You should also have a network card / USB NIC if your laptop does not have one built in. It's worth noting that in 2025, most laptops do not come with built-in Ethernet ports, making an external network adapter essential for direct connections to datacenter equipment.</p>\n<h2 id=\"2-network-cable-tester\"><a class=\"heading-anchor\" href=\"#2-network-cable-tester\" aria-label=\"Link to this section\">#</a>2. Network Cable Tester</h2>\n<p>In a world of complex connections, a network cable tester is invaluable. It helps technicians quickly identify issues with Ethernet cables, ensuring data flows smoothly through the datacenter. FLUKE is a wonderful brand, the more expensive cable testers really do a great job. You want to be able to identify the problem down to the exact area of a cable. Don't go cheap here.</p>\n<h2 id=\"3-multimeter\"><a class=\"heading-anchor\" href=\"#3-multimeter\" aria-label=\"Link to this section\">#</a>3. Multimeter</h2>\n<p>A multimeter is the Swiss Army knife of electrical testing. It measures voltage, current, and resistance, helping technicians diagnose power-related issues in servers and other equipment.</p>\n<h2 id=\"4-screwdriver-set\"><a class=\"heading-anchor\" href=\"#4-screwdriver-set\" aria-label=\"Link to this section\">#</a>4. Screwdriver Set</h2>\n<p>A variety of screwdrivers is essential for opening server cases, rack-mounted equipment, and various hardware components. A good set includes both flathead and Phillips head screwdrivers in different sizes.</p>\n<h2 id=\"5-electrostatic-discharge-esd-wrist-strap\"><a class=\"heading-anchor\" href=\"#5-electrostatic-discharge-esd-wrist-strap\" aria-label=\"Link to this section\">#</a>5. Electrostatic Discharge (ESD) Wrist Strap</h2>\n<p>Static electricity can be a silent killer of sensitive electronic components. An ESD wrist strap grounds the technician, preventing accidental damage to expensive equipment.</p>\n<h2 id=\"6-flashlight-or-headlamp\"><a class=\"heading-anchor\" href=\"#6-flashlight-or-headlamp\" aria-label=\"Link to this section\">#</a>6. Flashlight or Headlamp</h2>\n<p>Datacenters can be dimly lit, especially behind server racks. A good flashlight or headlamp helps technicians see clearly when working in tight spaces.</p>\n<h2 id=\"7-label-maker\"><a class=\"heading-anchor\" href=\"#7-label-maker\" aria-label=\"Link to this section\">#</a>7. Label Maker</h2>\n<p>Organization is key in a datacenter. A label maker helps technicians clearly mark cables, servers, and other equipment, making future maintenance much easier.</p>\n<h2 id=\"8-zip-ties-and-velcro-straps\"><a class=\"heading-anchor\" href=\"#8-zip-ties-and-velcro-straps\" aria-label=\"Link to this section\">#</a>8. Zip Ties and Velcro Straps</h2>\n<p>Cable management is crucial for airflow and organization. Zip ties and Velcro straps help bundle and secure cables neatly.</p>\n<h2 id=\"9-thermal-imaging-camera\"><a class=\"heading-anchor\" href=\"#9-thermal-imaging-camera\" aria-label=\"Link to this section\">#</a>9. Thermal Imaging Camera</h2>\n<p>While not always necessary, a thermal imaging camera can be incredibly useful for identifying hot spots in the datacenter, helping prevent equipment failures due to overheating.</p>\n<h2 id=\"10-safety-gear\"><a class=\"heading-anchor\" href=\"#10-safety-gear\" aria-label=\"Link to this section\">#</a>10. Safety Gear</h2>\n<p>Safety should always come first. Technicians often carry personal protective equipment like safety glasses, gloves, and sometimes even ear protection for noisy environments. I usually bring earbuds or bluetooth headphones to play music and drown out the datacenter noise.</p>\n<h2 id=\"11-cables-and-crimp-kit\"><a class=\"heading-anchor\" href=\"#11-cables-and-crimp-kit\" aria-label=\"Link to this section\">#</a>11. Cables and Crimp Kit</h2>\n<p>Always bring cables. Extra cables. Fiber patch cables, Ethernet cables, power cables, you name it. You'll never know when you must swap a cable. Things happen.</p>\n<h2 id=\"conclusion\"><a class=\"heading-anchor\" href=\"#conclusion\" aria-label=\"Link to this section\">#</a>Conclusion</h2>\n<p>The tools a datacenter technician carries reflect the complex and critical nature of their work. From diagnosing network issues to ensuring proper cable management, these professionals rely on a diverse toolkit to keep our digital infrastructure running smoothly. While the average person might not need these tools in their daily life, understanding their use gives us a glimpse into the intricate world of datacenter maintenance and the skilled professionals who keep our data flowing.</p>",
      "summary": "A comprehensive guide to the tools and equipment a datacenter technician needs for a successful site visit.",
      "date_published": "2024-02-19T00:00:00.000Z",
      "tags": [
        "Datacenter",
        "Hardware",
        "Field Notes"
      ],
      "image": "https://jarrettwilliams.com/assets/images/social-card-datacenter-technician-tools.778acecb.png"
    },
    {
      "id": "https://jarrettwilliams.com/blog/change-primary-user-microsoft-endpoint/",
      "url": "https://jarrettwilliams.com/blog/change-primary-user-microsoft-endpoint/",
      "title": "How to change the primary user of a device in Microsoft Endpoint from the last logged in user. Via Powershell. Via Graph API. In bulk.",
      "content_html": "<p>Alright, I figured it would be good to start posting some tips and tricks to the world. I manage a Microsoft 365 E5 environment and utilize Autopilot for the quick deployment of computers. As you go through hundreds of deployments, day after day, you do get set on autopilot. It works well. A year goes by and look at that, your IT Deployment techs are the primary users on so many laptops in Endpoint. How do we fix this?</p>\n<p>Well, I have the script for you. Run this in Powershell ISE with enough permissions to poll Graph API. Make sure you have the Graph API Powershell module imported.</p>\n<p>Install with `Install-Module -Name Microsoft.Graph.Intune`</p>\n<p>Now, the script:</p>\n<div class=\"code-block\"><span class=\"code-lang\">powershell</span><pre><code class=\"language-powershell\"><span class=\"tok-comment\"># Connect-MgGraph -Scopes &quot;DeviceManagementManagedDevices.ReadWrite.All&quot;, &quot;User.Read.All&quot;</span>\n<span class=\"tok-comment\"># Function to get the last logged-on user for a device</span>\nfunction <span class=\"tok-fn\">Get-LastLoggedOnUser</span> {\n    param (\n        [string]<span class=\"tok-var\">$DeviceId</span>\n    )\n    <span class=\"tok-var\">$device</span> = <span class=\"tok-fn\">Get-MgDeviceManagementManagedDevice</span> <span class=\"tok-flag\">-DeviceId</span> <span class=\"tok-var\">$DeviceId</span>\n    if (<span class=\"tok-var\">$device</span>.usersLoggedOn <span class=\"tok-flag\">-and</span> <span class=\"tok-var\">$device</span>.usersLoggedOn.Count <span class=\"tok-flag\">-gt</span> <span class=\"tok-num\">0</span>) {\n        <span class=\"tok-comment\"># Assuming the last item in usersLoggedOn is the most recent</span>\n        <span class=\"tok-var\">$lastUser</span> = <span class=\"tok-var\">$device</span>.usersLoggedOn | <span class=\"tok-fn\">Sort-Object</span> <span class=\"tok-flag\">-Property</span> lastLogOnDateTime <span class=\"tok-flag\">-Descending</span> | <span class=\"tok-fn\">Select-Object</span> <span class=\"tok-flag\">-First</span> <span class=\"tok-num\">1</span>\n        return <span class=\"tok-var\">$lastUser</span>.userId\n    }\n    return <span class=\"tok-var\">$null</span>\n}\n\n<span class=\"tok-comment\"># Get all Windows 10 and 11 devices</span>\n<span class=\"tok-var\">$devices</span> = <span class=\"tok-fn\">Get-MgDeviceManagementManagedDevice</span> <span class=\"tok-flag\">-Filter</span> <span class=\"tok-string\">&quot;operatingSystem eq &#39;Windows&#39; and (operatingSystemVersion startswith &#39;10.&#39; or operatingSystemVersion startswith &#39;11.&#39;)&quot;</span>\n\nforeach (<span class=\"tok-var\">$device</span> in <span class=\"tok-var\">$devices</span>) {\n    try {\n        <span class=\"tok-var\">$lastUserId</span> = <span class=\"tok-fn\">Get-LastLoggedOnUser</span> <span class=\"tok-flag\">-DeviceId</span> <span class=\"tok-var\">$device</span>.id\n        if (<span class=\"tok-var\">$lastUserId</span>) {\n            <span class=\"tok-var\">$lastUser</span> = <span class=\"tok-fn\">Get-MgUser</span> <span class=\"tok-flag\">-UserId</span> <span class=\"tok-var\">$lastUserId</span>\n            <span class=\"tok-comment\"># Check if the current primary user is different from the last logged on user</span>\n            if (<span class=\"tok-var\">$device</span>.userDisplayName <span class=\"tok-flag\">-ne</span> <span class=\"tok-var\">$lastUser</span>.displayName) {\n                <span class=\"tok-var\">$body</span> = @{\n                    <span class=\"tok-string\">&quot;userId&quot;</span> = <span class=\"tok-var\">$lastUserId</span>\n                }\n                <span class=\"tok-comment\"># Update the primary user</span>\n                <span class=\"tok-fn\">Update-MgDeviceManagementManagedDevice</span> <span class=\"tok-flag\">-ManagedDeviceId</span> <span class=\"tok-var\">$device</span>.id <span class=\"tok-flag\">-BodyParameter</span> <span class=\"tok-var\">$body</span>\n                <span class=\"tok-fn\">Write-Host</span> <span class=\"tok-string\">&quot;Updated primary user to $($lastUser.displayName) for device: $($device.deviceName)&quot;</span>\n            } else {\n                <span class=\"tok-fn\">Write-Host</span> <span class=\"tok-string\">&quot;Current primary user is already correct for device: $($device.deviceName)&quot;</span>\n            }\n        } else {\n            <span class=\"tok-fn\">Write-Host</span> <span class=\"tok-string\">&quot;No last logged on user found for device: $($device.deviceName)&quot;</span>\n        }\n    } catch {\n        <span class=\"tok-fn\">Write-Host</span> <span class=\"tok-string\">&quot;Failed to update primary user for device $($device.deviceName): $_&quot;</span>\n    }\n}</code></pre></div>\n<p><strong>Get-LastLoggedOnUser:</strong> A helper function to find the last user who logged on to a device by checking the <code>usersLoggedOn</code> property. This assumes the last item in this list is the most recent, which might not always be true if the list is not sorted by login time.</p>\n<p><strong>Get-MgDeviceManagementManagedDevice:</strong> Used to fetch Windows 10 and 11 devices from Intune.</p>\n<p><strong>Update-MgDeviceManagementManagedDevice:</strong> Updates the device&#39;s primary user to the last logged-on user if they differ from the current primary user.</p>\n<p><strong>Error Handling:</strong> Basic error handling to catch and report issues with updating each device.</p>\n<p><strong>Important Notes:</strong></p>\n<ul><li>The script assumes you have already authenticated with Microsoft Graph. If not, add or ensure you have run the <code>Connect-MgGraph</code> command with the right scopes.</li><li>This script uses the <code>usersLoggedOn</code> property to determine the last user, which might not always be up to date or accurate based on how Intune logs user activity.</li><li>Test this script in a controlled environment before running it in production to ensure it behaves as expected.</li></ul>",
      "summary": "A guide on updating the primary user of devices in Microsoft Endpoint using PowerShell and Graph API.",
      "date_published": "2024-02-18T00:00:00.000Z",
      "tags": [
        "Microsoft Intune",
        "PowerShell",
        "Automation"
      ],
      "image": "https://jarrettwilliams.com/assets/images/social-card-change-primary-user-microsoft-endpoint.27804f5b.png"
    },
    {
      "id": "https://jarrettwilliams.com/blog/disable-bluetooth-menu-macos/",
      "url": "https://jarrettwilliams.com/blog/disable-bluetooth-menu-macos/",
      "title": "Disable the bluetooth menu on macOS with a mobileconfig .plist file",
      "content_html": "<p>I do not have a ton of time, but I do have a bluetooth problem.</p>\n<p>What we have here is a plist for a MDM like JAMF Pro that will disable the bluetooth menu on macOS.</p>\n<div class=\"code-block\"><span class=\"code-lang\">xml</span><pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;\n&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot; &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;\n&lt;plist version=&quot;1.0&quot;&gt;\n&lt;dict&gt;\n    &lt;key&gt;Label&lt;/key&gt;\n    &lt;string&gt;com.example.disablebluetoothmenu&lt;/string&gt;\n    &lt;key&gt;ProgramArguments&lt;/key&gt;\n    &lt;array&gt;\n        &lt;string&gt;/usr/bin/defaults&lt;/string&gt;\n        &lt;string&gt;write&lt;/string&gt;\n        &lt;string&gt;com.apple.controlcenter&lt;/string&gt;\n        &lt;string&gt;Bluetooth&lt;/string&gt;\n        &lt;string&gt;-bool&lt;/string&gt;\n        &lt;string&gt;false&lt;/string&gt;\n    &lt;/array&gt;\n    &lt;key&gt;RunAtLoad&lt;/key&gt;\n    &lt;true/&gt;\n&lt;/dict&gt;\n&lt;/plist&gt;</code></pre></div>\n<p>This plist file, when deployed through a Mobile Device Management solution like JAMF Pro, will disable the Bluetooth menu in the macOS control center. It does this by setting the <code>Bluetooth</code> key in the <code>com.apple.controlcenter</code> domain to <code>false</code> using the <code>defaults</code> command.</p>\n<p>Here is a breakdown of what the plist does:</p>\n<ul><li>It sets a label for the task: <code>com.example.disablebluetoothmenu</code></li><li>It specifies the program to run: <code>/usr/bin/defaults</code></li><li>It provides arguments to the <code>defaults</code> command to write the Bluetooth setting</li><li>It sets <code>RunAtLoad</code> to <code>true</code>, which means this will run when the configuration profile is loaded</li></ul>\n<p>This can be useful in environments where you want to restrict Bluetooth usage or simplify the user interface by removing unused options.</p>",
      "summary": "How to disable the Bluetooth menu on macOS using a mobileconfig .plist file.",
      "date_published": "2024-02-17T00:00:00.000Z",
      "tags": [
        "macOS",
        "Endpoint Management",
        "Configuration"
      ],
      "image": "https://jarrettwilliams.com/assets/images/social-card-disable-bluetooth-menu-macos.39baa626.png"
    },
    {
      "id": "https://jarrettwilliams.com/blog/hello-world/",
      "url": "https://jarrettwilliams.com/blog/hello-world/",
      "title": "Hello world!",
      "content_html": "<p>Welcome to my new blog about Information Technology and System Administration.</p>\n<p>I am Jarrett Williams, an IT professional with a passion for technology and problem-solving. This blog is my platform to share insights, tips, and experiences from the world of IT and system administration.</p>\n<p>Here is what you can expect from this blog:</p>\n<ul><li>In-depth tutorials on various IT topics</li><li>Best practices for system administration</li><li>Reviews of the latest tech tools and software</li><li>Personal experiences and lessons learned from the field</li><li>Discussions on emerging trends in IT</li></ul>\n<p>Whether you are a seasoned IT professional, a budding system administrator, or just someone interested in the world of technology, I hope you find valuable content here.</p>\n<p>Feel free to leave comments, ask questions, or suggest topics you would like me to cover. Let us learn and grow together in this ever-evolving field of Information Technology.</p>\n<p>Stay tuned for more posts coming soon.</p>",
      "summary": "Welcome to my new blog about Information Technology and System Administration.",
      "date_published": "2024-02-16T00:00:00.000Z",
      "tags": [
        "Meta",
        "Career"
      ],
      "image": "https://jarrettwilliams.com/assets/images/social-card-hello-world.fb8d250d.png"
    }
  ]
}
