sync: auto-sync from ACG-TECH03L at 2026-04-19 12:50:13
Author: Howard Enos Machine: ACG-TECH03L Timestamp: 2026-04-19 12:50:13
This commit is contained in:
@@ -4,50 +4,4 @@ Check this file at sync. Delete items after you've addressed them.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-18 — GuruRMM is now a git submodule
|
||||
|
||||
The `projects/msp-tools/guru-rmm/` folder is no longer tracked as regular
|
||||
files in claudetools. It is now a git submodule pointing to the live gururmm
|
||||
Gitea repo. You need to initialize it on ACG-TECH03L.
|
||||
|
||||
**Run this once after pulling:**
|
||||
|
||||
```bash
|
||||
cd D:/claudetools # or wherever your claudetools checkout is
|
||||
git submodule update --init projects/msp-tools/guru-rmm
|
||||
```
|
||||
|
||||
This clones the full gururmm repo into that folder. After that, pulling
|
||||
claudetools will NOT automatically update the submodule — you need to
|
||||
run this to get the latest gururmm code:
|
||||
|
||||
```bash
|
||||
git submodule update --remote projects/msp-tools/guru-rmm
|
||||
```
|
||||
|
||||
**Working on gururmm code going forward:**
|
||||
|
||||
```bash
|
||||
cd projects/msp-tools/guru-rmm # this is now its own repo
|
||||
git pull # get latest
|
||||
# make changes, commit, push as normal
|
||||
cd ../../../ # back to claudetools root
|
||||
git add projects/msp-tools/guru-rmm
|
||||
git commit -m "chore: update gururmm submodule pointer"
|
||||
```
|
||||
|
||||
**Why this changed:**
|
||||
|
||||
The old copy in claudetools was drifting behind the live repo on the server.
|
||||
Features were being built in the gururmm repo that session logs in claudetools
|
||||
marked as "Done" — causing context loss between sessions. Submodule means
|
||||
one source of truth.
|
||||
|
||||
**Gitea credentials** for the submodule are stored in your local git config,
|
||||
not in .gitmodules. If the submodule can't authenticate, run:
|
||||
|
||||
```bash
|
||||
git config --local submodule."projects/msp-tools/guru-rmm".url \
|
||||
"https://azcomputerguru:Gptf%2A77ttb123%21%40%23-git@git.azcomputerguru.com/azcomputerguru/gururmm.git"
|
||||
git submodule sync
|
||||
```
|
||||
*No pending messages.*
|
||||
|
||||
42
.claude/messages/for-mike.md
Normal file
42
.claude/messages/for-mike.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Note for Mike
|
||||
|
||||
## From Howard, 2026-04-19
|
||||
|
||||
### Cascades of Tucson - M365 Remediation App - Identity Protection scope
|
||||
|
||||
During today's phishing investigation on Cascades of Tucson (crystal.rodriguez, et al.), the 10-point breach check returned `Forbidden` on `/identityProtection/riskyUsers` and `/identityProtection/riskDetections` because **Claude-MSP-Access (ComputerGuru - AI Remediation, App ID `fabb3421-8b34-484b-bc17-e46de9703418`) lacks admin consent for `IdentityRiskyUser.Read.All` on the Cascades tenant.**
|
||||
|
||||
**Asking before I grant:** should I go ahead and give this consent, or do you want to hold off?
|
||||
|
||||
#### What the scope does
|
||||
|
||||
- **Read-only.** Reads Entra ID Identity Protection signals: risky-user state (low/medium/high), and the underlying risk detections (impossible travel, anonymous IP, leaked credentials, malware-linked IP, etc.).
|
||||
- **No write capability** - not `ReadWrite.All`, just `Read.All`. The app cannot reset risk state, dismiss detections, or modify anything in Identity Protection.
|
||||
- **Tenant-scoped.** Consent applies only to the Cascades tenant; doesn't affect other clients.
|
||||
|
||||
#### Why I want it
|
||||
|
||||
- Closes a visibility gap in our standard breach-check workflow. Today I had to tell the report "this check skipped" for risky-user signals.
|
||||
- Saves us from logging into the Defender / Entra portal manually during IR to cross-check.
|
||||
- Cascades has Defender P1+ (based on targeted-user protection already configured), so risk data exists to read.
|
||||
|
||||
#### Why you might say no
|
||||
|
||||
- Every additional scope on the app = larger blast radius if the app's client secret/cert leaks.
|
||||
- Scope is persistent until revoked via the portal.
|
||||
- Identity Protection data can include sensitive info (IPs, geo, device hints). If our audit logging is weak, reading it leaves tracks we should be aware of.
|
||||
|
||||
#### My lean
|
||||
|
||||
**Allow it.** The scope is read-only, the app is narrowly controlled (only us), and we already have Mail.Read, User.Read.All, Exchange Admin, etc. — which are materially more sensitive than this. The inconsistency of "we can read full mailbox contents but not risky-user flags" doesn't match a risk-based model.
|
||||
|
||||
If you say yes, consent URL is:
|
||||
```
|
||||
https://login.microsoftonline.com/207fa277-e9d8-4eb7-ada1-1064d2221498/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418
|
||||
```
|
||||
|
||||
Takes ~30 seconds. Sign in as a GA on Cascades' tenant (sysadmin@ works), review the permission, click Accept.
|
||||
|
||||
Full investigation report: `clients/cascades-tucson/reports/2026-04-19-crystal-rodriguez-phish-investigation.md`
|
||||
|
||||
- Howard
|
||||
@@ -0,0 +1,42 @@
|
||||
# Cover email — sending the HTML staff editor
|
||||
|
||||
**To:** Meredith Kuhn, John Trozzi (cc: Ashley Jensen)
|
||||
**From:** Howard Enos — Computer Guru
|
||||
**Date:** 2026-04-18
|
||||
**Subject:** Optional tool to edit the staff list — try this if the Word doc feels heavy
|
||||
|
||||
---
|
||||
|
||||
Meredith / John,
|
||||
|
||||
I had some free time and I made this HTML file that may or may not help.
|
||||
|
||||
It's an alternative to marking up the Word questionnaire — same data, same questions, just in a format that might be faster to work through on a computer. Double-click the file and it opens in Edge, Chrome, Safari, whatever you have. Nothing to install, no login, no account to make.
|
||||
|
||||
**What you get:**
|
||||
|
||||
- Every staff member already grouped by department, pulled from what's in Active Directory today
|
||||
- Drag the `⋮⋮` grip next to a name to move them to a different department (instead of retyping names in another table)
|
||||
- Click any name or title to correct the spelling or title inline — no form fields, just click and type
|
||||
- A notes box under each name for freeform comments — spelling concerns, "this person is leaving in June," "they actually belong in Memory Care," whatever you want me to know about that person
|
||||
- Access-type buttons for each person: **D** = desktop only, **P** = phone only, **D+P** = both, **—** = leave blank if you're not sure and we'll decide together
|
||||
- **Outside** (building) and **ALIS** are separate checkboxes — tick them independently for whoever needs them
|
||||
- **+ Add** row at the bottom of every department if I've missed someone
|
||||
|
||||
A few people already have notes pre-filled where I had open questions — Matt Brooks' department, Christine Nyanzunda's one-or-two-accounts question, Kyla Quick Tiffany's name spelling, Patricia Sandoval-Beck's hyphen, and a few caregiver names I wasn't confident on. Answer those in the notes box next to each name and I'll have everything I need.
|
||||
|
||||
**How to send it back:**
|
||||
|
||||
Your edits autosave to the browser as you type. When you're done, click the green **Save to File** button at the top — that downloads a new copy of the HTML file with everything baked in. Email that copy back to me and I'll see every change. If you'd rather, **Export JSON** or **Export CSV** buttons also work. Any of the three is fine — whichever is easiest on your end.
|
||||
|
||||
If you switch computers partway through, use Save to File and open the downloaded copy on the other machine — your edits travel with the file.
|
||||
|
||||
**If the Word doc is easier, use the Word doc.** This is not a replacement, just another option. Whichever format gets us accurate answers is the right tool.
|
||||
|
||||
Thank you —
|
||||
|
||||
Howard
|
||||
|
||||
---
|
||||
|
||||
*Draft — prepared 2026-04-18 as a cover email for the cascades-staff-editor.html attachment.*
|
||||
@@ -0,0 +1,773 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Cascades — Staff & Department Editor</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#f4f6fa; --panel:#fff; --line:#d7dce4; --ink:#1a2030;
|
||||
--muted:#5c6678; --accent:#2b6cb0; --accent-ink:#fff;
|
||||
--caregiver:#fff6e5; --caregiver-line:#f1c27d;
|
||||
--staff:#eef3fb; --staff-line:#c5d5ec;
|
||||
--warn:#b42318; --ok:#15803d; --drop:#fff8c5;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; background:var(--bg); color:var(--ink);
|
||||
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
header {
|
||||
position:sticky; top:0; z-index:5; background:#1a2030; color:#fff;
|
||||
padding:10px 16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.15);
|
||||
}
|
||||
header h1 { font-size:16px; margin:0; flex:1; font-weight:600; }
|
||||
header .sub { font-size:12px; color:#9aa4b8; margin-left:6px; }
|
||||
button {
|
||||
background:#2b6cb0; color:#fff; border:0; border-radius:5px;
|
||||
padding:7px 12px; font:inherit; cursor:pointer;
|
||||
}
|
||||
button:hover { background:#224f84; }
|
||||
button.ghost { background:transparent; color:#cfd6e4; border:1px solid #3a4458; }
|
||||
button.ghost:hover { background:#2a3346; }
|
||||
button.danger { background:#b42318; }
|
||||
button.danger:hover { background:#8a1a11; }
|
||||
|
||||
main { padding:14px 16px 80px; max-width:1300px; margin:0 auto; }
|
||||
.hint {
|
||||
background:#fffbe6; border:1px solid #f1d982; color:#5c4a00;
|
||||
padding:8px 12px; border-radius:6px; margin:0 0 14px; font-size:13px;
|
||||
}
|
||||
.hint b { color:#3d3200; }
|
||||
|
||||
.dept {
|
||||
background:var(--panel); border:1px solid var(--line); border-radius:8px;
|
||||
margin:10px 0; padding:0; overflow:hidden;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.06);
|
||||
}
|
||||
.dept-header {
|
||||
padding:10px 14px; background:#eef2f7; border-bottom:1px solid var(--line);
|
||||
display:flex; gap:10px; align-items:center;
|
||||
}
|
||||
.dept-name { font-weight:600; font-size:15px; flex:1; outline:none; }
|
||||
.dept-name:focus { background:#fff; padding:2px 6px; border-radius:4px; }
|
||||
.count {
|
||||
background:#fff; border:1px solid var(--line); color:var(--muted);
|
||||
padding:2px 8px; border-radius:10px; font-size:12px;
|
||||
}
|
||||
.dept-actions { display:flex; gap:6px; }
|
||||
.dept-actions button { padding:4px 8px; font-size:12px; }
|
||||
|
||||
.people { padding:6px 8px 10px; }
|
||||
.person {
|
||||
display:grid;
|
||||
grid-template-columns: 18px 1.4fr 1.6fr auto auto auto auto auto auto;
|
||||
gap:6px; align-items:center;
|
||||
padding:6px 8px; margin:4px 0;
|
||||
border:1px solid transparent; border-radius:5px;
|
||||
background:var(--staff); border-color:var(--staff-line);
|
||||
}
|
||||
.person.caregiver { background:var(--caregiver); border-color:var(--caregiver-line); }
|
||||
.person.dragging { opacity:.35; }
|
||||
.person.drop-over { outline:2px dashed var(--accent); outline-offset:-2px; }
|
||||
.grip {
|
||||
cursor:grab; color:var(--muted); user-select:none; text-align:center;
|
||||
font-size:16px; line-height:1;
|
||||
}
|
||||
.grip:active { cursor:grabbing; }
|
||||
.field {
|
||||
border:1px solid transparent; padding:4px 6px; border-radius:4px;
|
||||
min-width:40px; outline:none;
|
||||
}
|
||||
.field:hover { border-color:#cfd6e4; background:#fff; }
|
||||
.field:focus { border-color:var(--accent); background:#fff; }
|
||||
.name { font-weight:600; }
|
||||
.title { color:var(--muted); font-size:13px; }
|
||||
|
||||
.seg {
|
||||
display:inline-flex; border:1px solid #c5cddb; border-radius:4px;
|
||||
background:#fff; overflow:hidden; font-size:12px;
|
||||
}
|
||||
.seg label {
|
||||
padding:3px 7px; cursor:pointer; user-select:none;
|
||||
border-right:1px solid #e5e9f0;
|
||||
}
|
||||
.seg label:last-child { border-right:0; }
|
||||
.seg input { display:none; }
|
||||
.seg input:checked + span {
|
||||
background:var(--accent); color:var(--accent-ink);
|
||||
margin:-3px -7px; padding:3px 7px; display:inline-block;
|
||||
}
|
||||
|
||||
.chk {
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
font-size:12px; color:var(--muted); cursor:pointer; user-select:none;
|
||||
padding:3px 6px; border:1px solid #c5cddb; border-radius:4px; background:#fff;
|
||||
}
|
||||
.chk input { accent-color:var(--accent); margin:0; }
|
||||
.chk.on { color:var(--ok); border-color:#9ad6a4; background:#eefaf0; }
|
||||
|
||||
.dropdown-wrap { position:relative; }
|
||||
select.dept-select {
|
||||
font:inherit; font-size:12px; padding:4px 6px;
|
||||
border:1px solid #c5cddb; border-radius:4px; background:#fff; max-width:150px;
|
||||
}
|
||||
.delete {
|
||||
background:transparent; color:var(--warn); padding:3px 8px; font-size:16px;
|
||||
line-height:1; border:1px solid transparent; border-radius:4px;
|
||||
}
|
||||
.delete:hover { background:#fde8e6; border-color:#f4b4ae; }
|
||||
|
||||
.notes {
|
||||
grid-column: 2 / -1;
|
||||
display:block; cursor:text;
|
||||
margin-top:4px; padding:6px 8px;
|
||||
border:1px dashed #cfd6e4; border-radius:4px;
|
||||
background:rgba(255,255,255,.7);
|
||||
font-size:12px; color:#2a3140; min-height:20px;
|
||||
outline:none; white-space:pre-wrap; word-wrap:break-word;
|
||||
}
|
||||
.notes:hover { border-color:#9aa4b8; background:#fff; }
|
||||
.notes:focus {
|
||||
border-style:solid; border-color:var(--accent); background:#fff;
|
||||
box-shadow:0 0 0 2px rgba(43,108,176,.15);
|
||||
}
|
||||
.notes:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color:#9aa4b8; font-style:italic;
|
||||
}
|
||||
.add-row {
|
||||
padding:6px 8px; display:flex; gap:6px; align-items:center;
|
||||
border-top:1px dashed #dbe0ea; margin-top:4px;
|
||||
}
|
||||
.add-row input {
|
||||
flex:1; padding:5px 8px; border:1px solid #c5cddb; border-radius:4px;
|
||||
font:inherit;
|
||||
}
|
||||
.add-row button { padding:5px 10px; font-size:13px; }
|
||||
|
||||
.drop-zone { min-height:24px; }
|
||||
.drop-zone.drop-over {
|
||||
background:var(--drop); border:2px dashed #caa300; border-radius:6px;
|
||||
}
|
||||
|
||||
footer {
|
||||
position:fixed; bottom:0; left:0; right:0;
|
||||
background:#fff; border-top:1px solid var(--line);
|
||||
padding:10px 16px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;
|
||||
box-shadow:0 -1px 3px rgba(0,0,0,.06);
|
||||
}
|
||||
footer .status { color:var(--muted); font-size:12px; flex:1; }
|
||||
.legend { color:var(--muted); font-size:12px; }
|
||||
.legend span { display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:middle; margin:0 4px 0 10px; }
|
||||
.legend .l-staff { background:var(--staff); border:1px solid var(--staff-line); }
|
||||
.legend .l-cg { background:var(--caregiver); border:1px solid var(--caregiver-line); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.person {
|
||||
grid-template-columns: 18px 1fr auto auto;
|
||||
}
|
||||
.person .title, .person .seg, .person .dropdown-wrap { grid-column: 2 / -1; }
|
||||
}
|
||||
@media print {
|
||||
header, footer, .dept-actions, .add-row, .delete, .grip { display:none !important; }
|
||||
.person { break-inside:avoid; background:#fff !important; border-color:#bbb !important; }
|
||||
.dept { break-inside:avoid; page-break-inside:avoid; }
|
||||
.dept-header { background:#eee !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Cascades — Staff & Department Editor</h1>
|
||||
<span class="sub" id="savedTag">loaded</span>
|
||||
<button onclick="saveToFile()" title="Download an updated copy of this HTML file with all your edits baked in. Works on any machine — just open the downloaded file." style="background:#15803d;">Save to File</button>
|
||||
<button onclick="exportJSON()" title="Download everything as JSON">Export JSON</button>
|
||||
<button onclick="exportCSV()" title="Download as CSV for spreadsheet">Export CSV</button>
|
||||
<button class="ghost" onclick="importJSON()">Import JSON</button>
|
||||
<button class="ghost" onclick="window.print()">Print</button>
|
||||
<button class="danger" onclick="resetAll()" title="Revert to the original roster">Reset</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="hint">
|
||||
<b>How to use:</b>
|
||||
<ul style="margin:6px 0 0; padding-left:22px;">
|
||||
<li><b>Drag</b> the <b>⋮⋮</b> grip on any name to move them to another department — drop on the department header or anywhere in its list.</li>
|
||||
<li><b>Click</b> any name or title to edit it inline.</li>
|
||||
<li><b>Notes box</b> under each name — click it to add spelling concerns, department conflicts, schedule notes, or anything else we should know about that person.</li>
|
||||
<li>
|
||||
<b>Access type</b> (pick one):
|
||||
<b>D</b> = desktop / PC only,
|
||||
<b>P</b> = phone only,
|
||||
<b>D+P</b> = both a desk and a shared phone,
|
||||
<b>—</b> = not set / leave blank if you're not sure and we'll decide together.
|
||||
</li>
|
||||
<li><b>Outside</b> is a <em>separate</em> checkbox — tick it if the person is allowed to sign in from outside the building (home, personal cell, travel). Leave it unchecked to lock them to Cascades. Works with any access type.</li>
|
||||
<li><b>ALIS</b> is a <em>separate</em> checkbox — tick it for anyone who logs into ALIS.</li>
|
||||
<li>Use the <b>+ Add</b> row at the bottom of a department to add a person we've missed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div id="board"></div>
|
||||
|
||||
<div style="margin-top:18px; display:flex; gap:8px; align-items:center;">
|
||||
<input id="newDept" placeholder="New department name (e.g. Security, Activities)" style="flex:1; padding:8px 10px; border:1px solid var(--line); border-radius:5px; font:inherit;">
|
||||
<button onclick="addDept()">+ Add department</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span class="status" id="status"> </span>
|
||||
<span class="legend">
|
||||
Legend: <span class="l-staff"></span>Staff <span class="l-cg"></span>Caregiver
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// -----------------------------------------------------------------------------
|
||||
// Initial roster — mirrors clients/cascades-tucson/scripts/generate-user-questionnaire.py
|
||||
// -----------------------------------------------------------------------------
|
||||
const INITIAL = {
|
||||
departments: [
|
||||
"Administrative","Marketing / Sales",
|
||||
"Care, Assisted Living (Nursing / Clinical)","Care, Memory Care",
|
||||
"Resident Services","Life Enrichment","Culinary","Maintenance",
|
||||
"Housekeeping","Transportation","Caregivers (shift staff)"
|
||||
],
|
||||
people: [
|
||||
// Staff
|
||||
["Meredith Kuhn","Executive Director","Administrative","D+P",true,true,""],
|
||||
["Ashley Jensen","Assistant Executive Director","Administrative","D+P",true,true,""],
|
||||
["Lauren Hasselman","Business Office Director","Administrative","D",true,false,""],
|
||||
["Allison Reibschied","Accounting Assistant","Administrative","D",false,false,""],
|
||||
|
||||
["Megan Hiatt","Sales Director","Marketing / Sales","D+P",true,true,"Handles resident intake (PHI)"],
|
||||
["Crystal Rodriguez","Sales Associate","Marketing / Sales","D+P",true,true,"Handles resident intake (PHI)"],
|
||||
["Tamra Matthews","Move-In Coordinator","Marketing / Sales","D+P",true,true,"Leaving June 2026 — confirm"],
|
||||
|
||||
["Lois Lane","Health Services Director","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,""],
|
||||
["Karen Rossini","Health Services Manager","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,""],
|
||||
["Britney Thompson","Memory Care Nurse","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,"Title says Memory Care — which department?"],
|
||||
["Veronica Feller","Care, Assisted Living Aide","Care, Assisted Living (Nursing / Clinical)","P",false,true,""],
|
||||
|
||||
["Shelby Trozzi","Memory Care Director","Care, Memory Care","D+P",true,true,""],
|
||||
["Christine Nyanzunda","Memory Care Admin Assistant","Care, Memory Care","D+P",true,true,"Also on caregiver list — same person?"],
|
||||
|
||||
["Christina DuPras","Resident Services Director","Resident Services","D+P",true,true,""],
|
||||
["Cathy Kingston","Receptionist","Resident Services","D",false,false,"Front desk shared PC"],
|
||||
["Shontiel Nunn","Receptionist","Resident Services","D",false,false,"Front desk shared PC"],
|
||||
["Kyla Quick Tiffany","Receptionist","Resident Services","D",false,false,"Is the spelling correct? Three separate names, or is it 'Quick-Tiffany' with a hyphen?"],
|
||||
["Michelle Shestko","MC Receptionist","Resident Services","D",false,false,"MC front desk shared PC"],
|
||||
["Sebastian Leon","Courtesy Patrol","Resident Services","P",false,false,""],
|
||||
["Sheldon Gardfrey","Courtesy Patrol","Resident Services","P",false,false,""],
|
||||
["Ray Rai","Courtesy Patrol","Resident Services","P",false,false,""],
|
||||
|
||||
["Susan Hicks","Life Enrichment Director","Life Enrichment","D",true,false,""],
|
||||
["Sharon Edwards","Life Enrichment Assistant","Life Enrichment","D",false,false,""],
|
||||
|
||||
["JD Martin","Culinary Director","Culinary","D+P",false,false,""],
|
||||
["Ramon Castaneda","Kitchen Manager","Culinary","D+P",false,false,""],
|
||||
["Alyssa Brooks","Dining Manager","Culinary","D+P",false,false,""],
|
||||
|
||||
["John Trozzi","Maintenance Director","Maintenance","D+P",true,false,""],
|
||||
["Matt Brooks","Memory Care Receptionist","Maintenance","D+P",false,true,"HR says Maintenance — which is correct?"],
|
||||
|
||||
["Lupe Sanchez","Housekeeping Director","Housekeeping","D+P",false,false,"AKA Guadalupe Sanchez"],
|
||||
|
||||
["Richard Adams","Driver","Transportation","P",false,false,""],
|
||||
["Julian Crim","Driver","Transportation","P",false,false,""],
|
||||
["Christopher Holick","Driver","Transportation","P",false,false,""],
|
||||
|
||||
// Caregivers (default P, no outside, no ALIS — flag ones that differ)
|
||||
["Thelma Abainza","Caregiver — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Niel Castro","MedTech / CCG — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Espe Esperance","MedTech — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Barbara Johnson","Caregiver — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Kasey Flores","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Richard Flores","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Marie Kastner","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Bella Mendoza","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Rosa Morales","MedTech — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Sandra Padilla","MedTech / CCG — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Polett Pinazavala","MedTech — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,"Confirm spelling"],
|
||||
["Whisper Reed","MedTech — Tower overnight (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Patricia Sandoval-Beck","MedTech — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,"Hyphenated last name — correct?"],
|
||||
["Charity Sika","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Ederick Yuzon","Caregiver — Tower (Tue–Sat)","Caregivers (shift staff)","P",false,false,"Confirm spelling"],
|
||||
["Juan Andrade","Caregiver — Memory Care (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Jahmeka Clarke","MedTech — Memory Care (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Karina Aziakpo","MedTech / CCG — MC overnight (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Jinnelle Dittbenner","Caregiver — Tower (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Agnes McFerren","Caregiver — Tower (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Samuel Ramirez","Caregiver — Tower (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Erica Sanchez","Caregiver — Memory Care (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Katrina Wyzykowski","MedTech — Memory Care (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Corey Tate","Caregiver — Tower NOC (Sun–Thu)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Ashli Atwood","MedTech / CCG — MC overnight (Fri–Mon)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Cole Johnson","MedTech — Tower (Fri–Mon)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Roseline Cooper","Caregiver — MC overnight (Fri–Mon)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Monique Lopez","Caregiver — Tower Fri+Sat doubles","Caregivers (shift staff)","P",false,false,""],
|
||||
["Gloria Williford","MedTech — MC Fri+Sat doubles","Caregivers (shift staff)","P",false,false,""],
|
||||
["Sarah Carroll","Caregiver — Tower (Thu–Mon)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Luke Hogan","Caregiver — Tower (Thu–Mon)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Gina Williams","Caregiver — Tower (Thu–Mon)","Caregivers (shift staff)","P",false,false,""],
|
||||
["Jen Higdon","Caregiver — Tower M/W/F AM","Caregivers (shift staff)","P",false,false,""],
|
||||
["Mary Kariuki","Caregiver — Tower Sat–Mon + Wed PM","Caregivers (shift staff)","P",false,false,""],
|
||||
["CeCe Lassey","Caregiver — Tower Sun/Mon doubles + Tue PM","Caregivers (shift staff)","P",false,false,""],
|
||||
["Paty Doran","MedTech / CCG — Tower Sun/Mon only","Caregivers (shift staff)","P",false,false,"Paty, Patti, or Patricia?"],
|
||||
["Ezekiel Huerta","Caregiver PRN — Tower","Caregivers (shift staff)","P",false,false,""],
|
||||
["Maia Baker","MedTech PRN — Memory Care","Caregivers (shift staff)","P",false,false,"Is she still employed?"],
|
||||
]
|
||||
};
|
||||
const CAREGIVER_DEPT = "Caregivers (shift staff)";
|
||||
const STORAGE_KEY = "cascades-staff-editor-v2";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------------
|
||||
let state = pickInitialState();
|
||||
|
||||
function pickInitialState() {
|
||||
// Priority: baked-in state from a previous "Save to File" (if it's the newest),
|
||||
// then this-browser localStorage, then the original roster.
|
||||
const embedded = (typeof window !== "undefined" && window.__SAVED_STATE__) || null;
|
||||
const local = load();
|
||||
if (embedded && local) {
|
||||
const emT = new Date(embedded.savedAt || 0).getTime();
|
||||
const loT = new Date(local.savedAt || 0).getTime();
|
||||
return emT >= loT ? embedded : local;
|
||||
}
|
||||
return embedded || local || rebuild(INITIAL);
|
||||
}
|
||||
|
||||
function rebuild(src) {
|
||||
let pid = 1;
|
||||
return {
|
||||
departments: [...src.departments],
|
||||
people: src.people.map(p => ({
|
||||
id: pid++, name: p[0], title: p[1], dept: p[2],
|
||||
access: p[3], outside: !!p[4], alis: !!p[5], notes: p[6] || ""
|
||||
})),
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
function save() {
|
||||
state.savedAt = new Date().toISOString();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
document.getElementById("savedTag").textContent = "saved " + new Date().toLocaleTimeString();
|
||||
} catch(e) { document.getElementById("savedTag").textContent = "save failed"; }
|
||||
}
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const obj = JSON.parse(raw);
|
||||
if (!obj.departments || !obj.people) return null;
|
||||
return obj;
|
||||
} catch(e) { return null; }
|
||||
}
|
||||
function resetAll() {
|
||||
if (!confirm("Reset everything back to the original Howard-provided roster? Your edits will be lost.")) return;
|
||||
state = rebuild(INITIAL);
|
||||
save(); render();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------------
|
||||
function render() {
|
||||
const board = document.getElementById("board");
|
||||
board.innerHTML = "";
|
||||
|
||||
// Ensure every person's dept still exists; if not, put them in an Unsorted dept
|
||||
const deptSet = new Set(state.departments);
|
||||
for (const p of state.people) {
|
||||
if (!deptSet.has(p.dept)) {
|
||||
if (!deptSet.has("Unsorted")) { state.departments.push("Unsorted"); deptSet.add("Unsorted"); }
|
||||
p.dept = "Unsorted";
|
||||
}
|
||||
}
|
||||
|
||||
for (const dept of state.departments) {
|
||||
const peopleInDept = state.people.filter(p => p.dept === dept);
|
||||
const det = document.createElement("div");
|
||||
det.className = "dept"; det.dataset.dept = dept;
|
||||
|
||||
const sum = document.createElement("div");
|
||||
sum.className = "dept-header";
|
||||
sum.innerHTML = `
|
||||
<span class="dept-name" contenteditable spellcheck="false"
|
||||
onblur="renameDept(this, '${escapeAttr(dept)}')"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur();}">${escapeHTML(dept)}</span>
|
||||
<span class="count">${peopleInDept.length}</span>
|
||||
<span class="dept-actions">
|
||||
<button onclick="moveDeptUp('${escapeAttr(dept)}')" title="Move up">↑</button>
|
||||
<button onclick="moveDeptDown('${escapeAttr(dept)}')" title="Move down">↓</button>
|
||||
<button class="danger" onclick="deleteDept('${escapeAttr(dept)}')" title="Delete department (moves members to Unsorted)">✕</button>
|
||||
</span>`;
|
||||
det.appendChild(sum);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "people drop-zone";
|
||||
list.dataset.dept = dept;
|
||||
list.addEventListener("dragover", onDragOver);
|
||||
list.addEventListener("dragleave", onDragLeave);
|
||||
list.addEventListener("drop", onDrop);
|
||||
sum.addEventListener("dragover", onDragOver);
|
||||
sum.addEventListener("dragleave", onDragLeave);
|
||||
sum.addEventListener("drop", onDrop);
|
||||
|
||||
for (const p of peopleInDept) list.appendChild(personRow(p));
|
||||
|
||||
// Add-person row
|
||||
const add = document.createElement("div");
|
||||
add.className = "add-row";
|
||||
add.innerHTML = `
|
||||
<input placeholder="New person — name" data-field="name">
|
||||
<input placeholder="Title / role" data-field="title" style="flex:0.8;">
|
||||
<button>+ Add</button>`;
|
||||
const btn = add.querySelector("button");
|
||||
btn.onclick = () => {
|
||||
const name = add.querySelector("[data-field=name]").value.trim();
|
||||
const title = add.querySelector("[data-field=title]").value.trim();
|
||||
if (!name) return;
|
||||
const isCg = dept === CAREGIVER_DEPT;
|
||||
state.people.push({
|
||||
id: nextId(), name, title, dept,
|
||||
access: isCg ? "P" : "D+P",
|
||||
outside: !isCg, alis: false, notes: ""
|
||||
});
|
||||
save(); render();
|
||||
};
|
||||
list.appendChild(add);
|
||||
det.appendChild(list);
|
||||
board.appendChild(det);
|
||||
}
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function personRow(p) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "person" + (p.dept === CAREGIVER_DEPT ? " caregiver" : "");
|
||||
row.dataset.id = p.id;
|
||||
// Only the grip handle is draggable — a draggable row would steal mousedown
|
||||
// from the contenteditable fields and break clicking inside the notes.
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="grip" draggable="true" title="Drag to move this person to another department">⋮⋮</span>
|
||||
<span class="field name" contenteditable spellcheck="false"
|
||||
data-id="${p.id}" data-k="name">${escapeHTML(p.name)}</span>
|
||||
<span class="field title" contenteditable spellcheck="false"
|
||||
data-id="${p.id}" data-k="title">${escapeHTML(p.title)}</span>
|
||||
<span class="seg" title="Access type">
|
||||
${accessSeg(p)}
|
||||
</span>
|
||||
<label class="chk ${p.outside ? "on" : ""}" title="Allowed to sign in outside the building">
|
||||
<input type="checkbox" ${p.outside ? "checked" : ""} data-id="${p.id}" data-k="outside">Outside
|
||||
</label>
|
||||
<label class="chk ${p.alis ? "on" : ""}" title="Uses ALIS">
|
||||
<input type="checkbox" ${p.alis ? "checked" : ""} data-id="${p.id}" data-k="alis">ALIS
|
||||
</label>
|
||||
<span class="dropdown-wrap">
|
||||
<select class="dept-select" data-id="${p.id}" title="Move to another department">
|
||||
${state.departments.map(d => `<option ${d===p.dept?"selected":""}>${escapeHTML(d)}</option>`).join("")}
|
||||
</select>
|
||||
</span>
|
||||
<button class="delete" title="Remove this person" data-id="${p.id}">✕</button>
|
||||
<span class="notes" contenteditable spellcheck="true"
|
||||
data-id="${p.id}" data-k="notes"
|
||||
data-placeholder="Click to add notes about this person — spelling, department conflicts, schedule, etc.">${escapeHTML(p.notes)}</span>
|
||||
`;
|
||||
|
||||
// Wire handlers (delegation would work too but this keeps it local)
|
||||
row.querySelectorAll("[contenteditable]").forEach(el => {
|
||||
el.addEventListener("blur", onFieldBlur);
|
||||
el.addEventListener("keydown", e => {
|
||||
// Enter blurs name/title, but in notes we allow multi-line
|
||||
if (e.key === "Enter" && !el.classList.contains("notes")) {
|
||||
e.preventDefault(); el.blur();
|
||||
}
|
||||
});
|
||||
});
|
||||
row.querySelectorAll(".notes").forEach(el => {
|
||||
el.addEventListener("mousedown", onNotesMouseDown);
|
||||
});
|
||||
row.querySelectorAll('input[type="radio"]').forEach(el => el.addEventListener("change", onAccessChange));
|
||||
row.querySelectorAll('input[type="checkbox"]').forEach(el => el.addEventListener("change", onToggle));
|
||||
row.querySelector("select.dept-select").addEventListener("change", onDeptSelect);
|
||||
row.querySelector("button.delete").addEventListener("click", onDelete);
|
||||
|
||||
// Grip is the only drag initiator
|
||||
const grip = row.querySelector(".grip");
|
||||
grip.addEventListener("dragstart", onDragStart);
|
||||
grip.addEventListener("dragend", onDragEnd);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function accessSeg(p) {
|
||||
// D / P / D+P are the real values. "—" is a clear / not-yet-decided option.
|
||||
// Users can also click a selected button again to deselect it.
|
||||
const opts = [
|
||||
{v:"D", label:"D", title:"Desktop / PC only"},
|
||||
{v:"P", label:"P", title:"Phone only"},
|
||||
{v:"D+P", label:"D+P", title:"Both desktop and phone"},
|
||||
{v:"", label:"—", title:"Not set / unknown"}
|
||||
];
|
||||
return opts.map(o => `
|
||||
<label title="${o.title}">
|
||||
<input type="radio" name="acc-${p.id}" value="${o.v}" ${p.access===o.v?"checked":""} data-id="${p.id}" data-k="access">
|
||||
<span>${o.label}</span>
|
||||
</label>`).join("");
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const total = state.people.length;
|
||||
const staff = state.people.filter(p => p.dept !== CAREGIVER_DEPT).length;
|
||||
const cg = total - staff;
|
||||
const outside = state.people.filter(p => p.outside).length;
|
||||
const alis = state.people.filter(p => p.alis).length;
|
||||
document.getElementById("status").textContent =
|
||||
`${total} people (${staff} staff + ${cg} caregiver) · ${outside} outside-access · ${alis} ALIS · ${state.departments.length} departments`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Field handlers
|
||||
// -----------------------------------------------------------------------------
|
||||
function onFieldBlur(e) {
|
||||
const id = +e.target.dataset.id, k = e.target.dataset.k;
|
||||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||||
const v = e.target.textContent.trim();
|
||||
if (p[k] !== v) { p[k] = v; save(); updateStatus(); }
|
||||
}
|
||||
function onAccessChange(e) {
|
||||
const id = +e.target.dataset.id;
|
||||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||||
p.access = e.target.value; save();
|
||||
}
|
||||
function onToggle(e) {
|
||||
const id = +e.target.dataset.id, k = e.target.dataset.k;
|
||||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||||
p[k] = e.target.checked;
|
||||
e.target.closest("label").classList.toggle("on", e.target.checked);
|
||||
save(); updateStatus();
|
||||
}
|
||||
function onDeptSelect(e) {
|
||||
const id = +e.target.dataset.id;
|
||||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||||
p.dept = e.target.value; save(); render();
|
||||
}
|
||||
function onDelete(e) {
|
||||
const id = +e.currentTarget.dataset.id;
|
||||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||||
if (!confirm(`Remove ${p.name} from the list? (You can add them back later.)`)) return;
|
||||
state.people = state.people.filter(x => x.id !== id);
|
||||
save(); render();
|
||||
}
|
||||
|
||||
function renameDept(el, oldName) {
|
||||
const newName = el.textContent.trim();
|
||||
if (!newName || newName === oldName) { el.textContent = oldName; return; }
|
||||
if (state.departments.includes(newName)) {
|
||||
alert("A department with that name already exists."); el.textContent = oldName; return;
|
||||
}
|
||||
const i = state.departments.indexOf(oldName);
|
||||
if (i >= 0) state.departments[i] = newName;
|
||||
for (const p of state.people) if (p.dept === oldName) p.dept = newName;
|
||||
save(); render();
|
||||
}
|
||||
function moveDeptUp(name) {
|
||||
const i = state.departments.indexOf(name); if (i <= 0) return;
|
||||
[state.departments[i-1], state.departments[i]] = [state.departments[i], state.departments[i-1]];
|
||||
save(); render();
|
||||
}
|
||||
function moveDeptDown(name) {
|
||||
const i = state.departments.indexOf(name); if (i < 0 || i >= state.departments.length-1) return;
|
||||
[state.departments[i+1], state.departments[i]] = [state.departments[i], state.departments[i+1]];
|
||||
save(); render();
|
||||
}
|
||||
function deleteDept(name) {
|
||||
const n = state.people.filter(p => p.dept === name).length;
|
||||
if (!confirm(`Delete department "${name}"?` + (n ? ` ${n} person(s) will move to Unsorted.` : ""))) return;
|
||||
state.departments = state.departments.filter(d => d !== name);
|
||||
for (const p of state.people) if (p.dept === name) p.dept = "Unsorted";
|
||||
if (state.people.some(p => p.dept === "Unsorted") && !state.departments.includes("Unsorted"))
|
||||
state.departments.push("Unsorted");
|
||||
save(); render();
|
||||
}
|
||||
function addDept() {
|
||||
const el = document.getElementById("newDept");
|
||||
const name = el.value.trim();
|
||||
if (!name) return;
|
||||
if (state.departments.includes(name)) { alert("That department already exists."); return; }
|
||||
state.departments.push(name); el.value = "";
|
||||
save(); render();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Drag & drop
|
||||
// -----------------------------------------------------------------------------
|
||||
let dragId = null;
|
||||
function onDragStart(e) {
|
||||
const row = e.currentTarget.closest(".person");
|
||||
if (!row) return;
|
||||
dragId = +row.dataset.id;
|
||||
row.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
try { e.dataTransfer.setData("text/plain", String(dragId)); } catch(_) {}
|
||||
try { e.dataTransfer.setDragImage(row, 20, 10); } catch(_) {}
|
||||
}
|
||||
function onDragEnd(e) {
|
||||
const row = e.currentTarget.closest(".person");
|
||||
if (row) row.classList.remove("dragging");
|
||||
document.querySelectorAll(".drop-over").forEach(n => n.classList.remove("drop-over"));
|
||||
dragId = null;
|
||||
}
|
||||
function onDragOver(e) {
|
||||
e.preventDefault();
|
||||
const zone = e.currentTarget;
|
||||
zone.classList.add("drop-over");
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
function onDragLeave(e) {
|
||||
e.currentTarget.classList.remove("drop-over");
|
||||
}
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
const zone = e.currentTarget;
|
||||
zone.classList.remove("drop-over");
|
||||
const dept = zone.dataset.dept || zone.closest(".dept")?.dataset.dept;
|
||||
if (!dept) return;
|
||||
const id = dragId || +e.dataTransfer.getData("text/plain");
|
||||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||||
if (p.dept === dept) return;
|
||||
p.dept = dept; save(); render();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Export / import
|
||||
// -----------------------------------------------------------------------------
|
||||
function saveToFile() {
|
||||
// Timestamp the state and persist to localStorage as well
|
||||
state.savedAt = new Date().toISOString();
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(_) {}
|
||||
|
||||
// Build a clone of the document, inject/update the embedded-state <script>,
|
||||
// strip transient UI bits, and serialize.
|
||||
const clone = document.documentElement.cloneNode(true);
|
||||
const headClone = clone.querySelector("head");
|
||||
let sEl = clone.querySelector("#saved-state");
|
||||
if (!sEl) {
|
||||
sEl = document.createElement("script");
|
||||
sEl.id = "saved-state";
|
||||
headClone.appendChild(sEl);
|
||||
}
|
||||
// Escape any close-script token that might appear in a notes field
|
||||
const json = JSON.stringify(state).replace(/<\/scr(?=ipt)/gi, "<\\/scr");
|
||||
sEl.textContent = "window.__SAVED_STATE__ = " + json + ";";
|
||||
|
||||
// Empty the rendered board — it's rebuilt from state on load, so we don't
|
||||
// need to carry the DOM. Also clear any transient input/UI state.
|
||||
const bc = clone.querySelector("#board"); if (bc) bc.innerHTML = "";
|
||||
const nd = clone.querySelector("#newDept"); if (nd) nd.removeAttribute("value");
|
||||
const st = clone.querySelector("#savedTag"); if (st) st.textContent = "loaded";
|
||||
clone.querySelectorAll(".dragging, .drop-over").forEach(n => {
|
||||
n.classList.remove("dragging"); n.classList.remove("drop-over");
|
||||
});
|
||||
|
||||
const html = "<!doctype html>\n" + clone.outerHTML;
|
||||
downloadBlob("cascades-staff-editor.html", html, "text/html");
|
||||
document.getElementById("savedTag").textContent = "file saved " + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
const data = JSON.stringify(state, null, 2);
|
||||
downloadBlob(`cascades-staff-${stamp()}.json`, data, "application/json");
|
||||
}
|
||||
function exportCSV() {
|
||||
const cols = ["Department","Name","Title / Role","Access","Outside Access","ALIS","Notes"];
|
||||
const rows = [cols];
|
||||
for (const dept of state.departments) {
|
||||
for (const p of state.people.filter(x => x.dept === dept)) {
|
||||
rows.push([dept, p.name, p.title, p.access,
|
||||
p.outside ? "Y":"N", p.alis ? "Y":"N", p.notes || ""]);
|
||||
}
|
||||
}
|
||||
const csv = rows.map(r => r.map(csvEscape).join(",")).join("\r\n");
|
||||
downloadBlob(`cascades-staff-${stamp()}.csv`, csv, "text/csv");
|
||||
}
|
||||
function importJSON() {
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "file"; inp.accept = ".json,application/json";
|
||||
inp.onchange = () => {
|
||||
const f = inp.files[0]; if (!f) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
try {
|
||||
const obj = JSON.parse(r.result);
|
||||
if (!obj.departments || !obj.people) throw new Error("Missing fields");
|
||||
state = obj; save(); render();
|
||||
alert("Imported OK.");
|
||||
} catch(e) { alert("Could not read that file: " + e.message); }
|
||||
};
|
||||
r.readAsText(f);
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
function downloadBlob(name, data, mime) {
|
||||
const blob = new Blob([data], {type: mime});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob); a.download = name;
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 0);
|
||||
}
|
||||
function csvEscape(v) {
|
||||
v = String(v ?? "");
|
||||
return /[",\r\n]/.test(v) ? `"${v.replace(/"/g,'""')}"` : v;
|
||||
}
|
||||
function stamp() {
|
||||
const d = new Date(), p = n => String(n).padStart(2,"0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -----------------------------------------------------------------------------
|
||||
function onNotesMouseDown(e) {
|
||||
// If the click doesn't land on a text position inside this element
|
||||
// (e.g. the user clicked past the end of the sentence or in the padding),
|
||||
// place the caret at the end so typing and backspace just work.
|
||||
const el = e.currentTarget;
|
||||
let inside = false;
|
||||
if (document.caretPositionFromPoint) {
|
||||
const p = document.caretPositionFromPoint(e.clientX, e.clientY);
|
||||
if (p && el.contains(p.offsetNode)) inside = true;
|
||||
} else if (document.caretRangeFromPoint) {
|
||||
const r = document.caretRangeFromPoint(e.clientX, e.clientY);
|
||||
if (r && el.contains(r.startContainer)) inside = true;
|
||||
}
|
||||
if (!inside) {
|
||||
e.preventDefault();
|
||||
el.focus();
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
}
|
||||
}
|
||||
|
||||
function nextId() { return Math.max(0, ...state.people.map(p => p.id)) + 1; }
|
||||
function escapeHTML(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escapeAttr(s) { return String(s ?? "").replace(/'/g, "\\'"); }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Go
|
||||
// -----------------------------------------------------------------------------
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,225 @@
|
||||
# Draft email — User + Department + Access confirmation
|
||||
|
||||
**To:** Meredith Kuhn, John Trozzi (cc: Ashley Jensen, HR)
|
||||
**From:** Howard Enos — Computer Guru
|
||||
**Date:** 2026-04-18
|
||||
**Subject:** Please review — full staff list, department, and access (before we buy licenses / create caregiver accounts)
|
||||
|
||||
---
|
||||
|
||||
Meredith / John,
|
||||
|
||||
Before we purchase the Microsoft 365 Business Premium licenses and start creating caregiver accounts, I want to make sure we have **every name, spelling, department, and how each person actually uses technology** correct. Changing this AFTER accounts are created is painful (email addresses, phone profiles, security groups all have to be rebuilt).
|
||||
|
||||
Please review the list below and mark it up — corrections, additions, or removals. You can reply directly in email, print and mark up, or we can walk through it on a call. A few notes on the columns:
|
||||
|
||||
- **Access type** — how they actually use technology:
|
||||
- **D** = Desktop / PC only (they sit at a computer, they do not need a company phone)
|
||||
- **P** = Phone only (shared Android phone on shift — no personal PC at Cascades)
|
||||
- **D+P** = Both (desk for office work, shared phone during rounds / on the floor)
|
||||
- **Outside access** — should this person be allowed to sign in from **outside the building** (home, personal cell, hotel, etc.)? Y = yes, N = no (locked to Cascades network/trusted device).
|
||||
- **ALIS** — does this person log into ALIS? ALIS is a website they sign into; it can be connected to their Microsoft account so they do NOT get a second MFA prompt — but only if we know who needs it.
|
||||
|
||||
Where I've filled in best guesses I've put them in parentheses — those are the ones I most need you to confirm or correct.
|
||||
|
||||
---
|
||||
|
||||
## 1. Administrative
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Meredith Kuhn | Executive Director | (D+P) | (Y) | (Y) |
|
||||
| Ashley Jensen | Assistant Executive Director | (D+P) | (Y) | (Y) |
|
||||
| Lauren Hasselman | Business Office Director | (D) | (Y) | (N) |
|
||||
| Allison Reibschied | Accounting Assistant | (D) | (N) | (N) |
|
||||
|
||||
## 2. Marketing / Sales
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Megan Hiatt | Sales Director | D+P | **Y** (home + personal cell — handles resident intake forms) | (Y — intake PHI) |
|
||||
| Crystal Rodriguez | Sales Associate | D+P | **Y** (home + personal cell — handles resident intake forms) | (Y — intake PHI) |
|
||||
| Tamra Matthews | Move-In Coordinator | (D+P) | **Y** (home + personal cell) — **leaving June 2026** | (Y — intake PHI) |
|
||||
|
||||
## 3. Care — Assisted Living (clinical / nursing)
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Lois Lane | Health Services Director | (D+P) | (Y) | (Y) |
|
||||
| Karen Rossini | Health Services Manager | (D+P) | (Y) | (Y) |
|
||||
| Britney Thompson | Memory Care Nurse | (D+P) | (Y) | (Y) |
|
||||
| Veronica Feller | Care, Assisted Living Aide | (P) | (N) | (Y) |
|
||||
|
||||
**Note:** Britney's AD department is currently "Care, Assisted Living" but her title says Memory Care. Which department should she belong to for licensing / security group purposes?
|
||||
|
||||
## 4. Care — Memory Care
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Shelby Trozzi | Memory Care Director | (D+P) | (Y) | (Y) |
|
||||
| Christine Nyanzunda | Memory Care Admin Assistant | (D+P) | (Y — or N?) | (Y) |
|
||||
|
||||
## 5. Resident Services
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Christina DuPras | Resident Services Director | (D+P) | (Y) | (Y) |
|
||||
| Cathy Kingston | Receptionist | (D — shared front desk PC) | (N) | (N) |
|
||||
| Shontiel Nunn | Receptionist | (D — shared front desk PC) | (N) | (N) |
|
||||
| Kyla Quick Tiffany | Receptionist — **not yet in AD** | (D — shared front desk PC) | (N) | (N) |
|
||||
| Michelle Shestko | MC Receptionist | (D — shared front desk PC) | (N) | (N) |
|
||||
| Sebastian Leon | Courtesy Patrol | (P — shared phone on rounds?) | (N) | (N) |
|
||||
| Sheldon Gardfrey | Courtesy Patrol | (P — shared phone on rounds?) | (N) | (N) |
|
||||
| Ray Rai | Courtesy Patrol | (P — shared phone on rounds?) | (N) | (N) |
|
||||
|
||||
**Question:** Do the Courtesy Patrol staff need email at all, or just a shared-phone identity? And is the spelling **Kyla Quick Tiffany** (three names) correct, or is it **Kyla Quick-Tiffany** (hyphen) or different?
|
||||
|
||||
## 6. Life Enrichment
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Susan Hicks | Life Enrichment Director | D | (Y — or N?) | (N) |
|
||||
| Sharon Edwards | Life Enrichment Assistant | D | (N) | (N) |
|
||||
|
||||
## 7. Culinary
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| JD Martin | Culinary Director | (D+P) | (N) | (N) |
|
||||
| Ramon Castaneda | Kitchen Manager | (D+P) | (N) | (N) |
|
||||
| Alyssa Brooks | Dining Manager | (D+P) | (N) | (N) |
|
||||
|
||||
**Note:** The kitchen has 9 shared iPads planned (separate from the 25 shared phones). Should kitchen staff sign in on those iPads with their own account, or stay on a shared kitchen iPad identity?
|
||||
|
||||
## 8. Maintenance
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| John Trozzi | Maintenance Director | (D+P) | (Y) | (N) |
|
||||
| Matt Brooks | Memory Care Receptionist | (D+P) | (N) | (Y?) |
|
||||
|
||||
**Question:** Matt Brooks' HR record shows department = **Maintenance** but his title is **Memory Care Receptionist**. Which is correct? That affects where he gets filed and what he has access to.
|
||||
|
||||
## 9. Housekeeping
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Lupe Sanchez (Guadalupe Sanchez) | Housekeeping Director | (D+P) | (N) | (N) |
|
||||
|
||||
## 10. Transportation
|
||||
|
||||
| Name | Title | Access | Outside Access | ALIS |
|
||||
|---|---|---|---|---|
|
||||
| Richard Adams | Driver | (P only) | (N) | (N) |
|
||||
| Julian Crim | Driver | (P only) | (N) | (N) |
|
||||
| Christopher Holick | Driver | (P only) | (N) | (N) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Caregivers — NEW (39 people, not yet in AD or M365)
|
||||
|
||||
These are the staff currently using shared workstation logins. None of them has an email account or Microsoft identity today. As part of the shared-phone + HIPAA project, each person would get their own login that gets used ONLY on the shared phones (and shared workstations via the same identity).
|
||||
|
||||
**Default assumption for every caregiver:** Phone-only (**P**), no outside access (**N** — locked to Cascades network + managed phone). Please flag anyone who should differ from that default (for example, a MedTech who also charts at a desktop would be **D+P**; a salaried staff member who also works from home would need outside access **Y**).
|
||||
|
||||
### Tuesday–Saturday shift (15)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm spelling / access |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Thelma Abainza | Caregiver | Tower | 520-867-2579 | |
|
||||
| 2 | Niel Castro | MedTech / CCG | Tower | 520-697-4644 | |
|
||||
| 3 | Espe Esperance | MedTech | Tower | 520-788-9558 | |
|
||||
| 4 | Barbara Johnson | Caregiver | Tower | 520-204-3449 | |
|
||||
| 5 | Kasey Flores | Caregiver | Memory Care | 520-250-1451 | |
|
||||
| 6 | Richard Flores | Caregiver | Memory Care | 520-873-7727 | |
|
||||
| 7 | Marie Kastner | Caregiver | Memory Care | 714-576-9858 | |
|
||||
| 8 | Bella Mendoza | Caregiver | Memory Care | 520-358-2000 | |
|
||||
| 9 | Rosa Morales | MedTech | Memory Care | 312-213-8780 | |
|
||||
| 10 | Sandra Padilla | MedTech / CCG | Tower | 520-585-3317 | |
|
||||
| 11 | **Polett Pinazavala** | MedTech | Memory Care | 520-449-5533 | **Please confirm spelling** |
|
||||
| 12 | Whisper Reed | MedTech | Tower (overnight) | 520-312-7575 | |
|
||||
| 13 | **Patricia Sandoval-Beck** | MedTech | Tower | 520-343-8093 | **Please confirm the hyphenated last name is correct** |
|
||||
| 14 | Charity Sika | Caregiver | Memory Care | 623-251-8032 | |
|
||||
| 15 | **Ederick Yuzon** | Caregiver | Tower | 520-603-8816 | **Please confirm spelling** |
|
||||
|
||||
### Sunday–Thursday shift (10)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm |
|
||||
|---|---|---|---|---|---|
|
||||
| 16 | Juan Andrade | Caregiver | Memory Care | 520-528-4078 | |
|
||||
| 17 | Jahmeka Clarke | MedTech | Memory Care | 520-649-7034 | |
|
||||
| 18 | Karina Aziakpo | MedTech / CCG | Memory Care (overnight) | 520-392-6859 | |
|
||||
| 19 | Jinnelle Dittbenner | Caregiver | Tower | 520-499-9996 | |
|
||||
| 20 | **Christine Nyanzunda** | MedTech | Memory Care (Sun/Mon AM) | 520-304-4251 | **Same person as the Memory Care Admin Assistant above?** We should only create ONE account. |
|
||||
| 21 | Agnes McFerren | Caregiver | Tower | 520-406-3063 | |
|
||||
| 22 | Samuel Ramirez | Caregiver | Tower | 520-488-5798 | |
|
||||
| 23 | Erica Sanchez | Caregiver | Memory Care | 520-528-3387 | |
|
||||
| 24 | Katrina Wyzykowski | MedTech | Memory Care | 520-347-1448 | |
|
||||
| 25 | Corey Tate | Caregiver (no MedTech) | Tower (NOC) | 520-535-7821 | |
|
||||
|
||||
### Friday–Monday / weekend (5)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm |
|
||||
|---|---|---|---|---|---|
|
||||
| 26 | Ashli Atwood | MedTech / CCG | Memory Care (overnight) | 715-200-1295 | |
|
||||
| 27 | Cole Johnson | MedTech | Tower | 818-970-0890 | |
|
||||
| 28 | Roseline Cooper | Caregiver | Memory Care (overnight) | 520-278-6817 | |
|
||||
| 29 | Monique Lopez | Caregiver (doubles Fri/Sat) | Tower | 520-596-0969 | |
|
||||
| 30 | Gloria Williford | MedTech (doubles Fri/Sat) | Memory Care | 928-551-1682 | |
|
||||
|
||||
### Thursday–Monday shift (3)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm |
|
||||
|---|---|---|---|---|---|
|
||||
| 31 | Sarah Carroll | Caregiver | Tower | 520-409-2341 | |
|
||||
| 32 | Luke Hogan | Caregiver | Tower | 520-312-0141 | |
|
||||
| 33 | Gina Williams | Caregiver | Tower | 520-612-5075 | |
|
||||
|
||||
### Split / partial-week (3)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm |
|
||||
|---|---|---|---|---|---|
|
||||
| 34 | Jen Higdon | Caregiver | Tower (M/W/F AM) | 520-730-3548 | |
|
||||
| 35 | Mary Kariuki | Caregiver | Tower (Sat–Mon + Wed PM) | 520-309-1247 | |
|
||||
| 36 | CeCe Lassey | Caregiver | Tower (Sun/Mon doubles + Tue PM) | 520-248-5982 | |
|
||||
|
||||
### Sunday & Monday only (1)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm |
|
||||
|---|---|---|---|---|---|
|
||||
| 37 | **Paty Doran** | MedTech / CCG | Tower | 520-591-7368 | **Is it Paty, Patti, or Patricia?** |
|
||||
|
||||
### PRN / float (2)
|
||||
|
||||
| # | Name | Role | Location | Phone | Confirm |
|
||||
|---|---|---|---|---|---|
|
||||
| 38 | Ezekiel Huerta | Caregiver (PRN) | Tower | 520-591-6113 | |
|
||||
| 39 | **Maia Baker** | MedTech (PRN) | Memory Care | TBD | **On a secondary sheet only, not the shift list — is she still employed?** |
|
||||
|
||||
---
|
||||
|
||||
## Things I most need you to confirm
|
||||
|
||||
1. **Spelling corrections** for any names flagged in bold above (Polett, Patricia Sandoval-Beck, Ederick, Paty, Maia, and Kyla Quick Tiffany).
|
||||
2. **Christine Nyanzunda** — one person, or two? If one, she keeps the one existing mailbox and we just extend her to the phones.
|
||||
3. **Matt Brooks** — department = Maintenance or Memory Care? Title = Memory Care Receptionist, but HR shows Maintenance.
|
||||
4. **Access type** on every row (D / P / D+P) — especially: do Courtesy Patrol, receptionists, and Drivers use shared phones, shared PCs, or both? Do MedTechs / CCGs chart at a desktop as well as on the phone, or only on the phone?
|
||||
5. **Outside access** on every row — who needs to work from home, take work email on their personal cell, or travel? The default for everyone else is **in-building only**, which is what HIPAA and Conditional Access would prefer.
|
||||
6. **ALIS access** — please confirm who actually logs into ALIS. We want to tie ALIS to Microsoft sign-in so those users get a smooth single sign-on with no extra MFA prompts — but only for people who use ALIS. If you don't use it, we won't connect it.
|
||||
7. **Anyone missing?** Reply with additions. Specifically: I don't have anyone listed under Activities/Life Enrichment outside Susan and Sharon, nor in Transportation beyond the three drivers. Are there PRN/float staff in any other department I should know about?
|
||||
8. **Tamra Matthews** — we have her transition out in June 2026 confirmed? The Premium license assignment would be temporary if so.
|
||||
|
||||
Once this list is confirmed, I will:
|
||||
1. Clean up the role-based email accounts (accounting@, frontdesk@, hr@, etc.) → shared mailboxes (saves ~$137/mo).
|
||||
2. Purchase the Business Premium licenses based on the confirmed count.
|
||||
3. Create the ~38 net-new caregiver accounts in AD and M365.
|
||||
4. Build the Conditional Access policies that enforce the "outside access" column — in-building users get locked to the building, outside-access users get the flexible "from managed device only" policy.
|
||||
5. Connect ALIS to Microsoft for the users who use it — that's the piece that removes the second MFA prompt.
|
||||
|
||||
Thank you — please mark this up at your convenience.
|
||||
|
||||
Howard
|
||||
|
||||
---
|
||||
|
||||
*Draft — prepared 2026-04-18 for Howard's review before sending.*
|
||||
@@ -0,0 +1,27 @@
|
||||
# User Awareness Email - "Recorded Message playback" Phish (2026-04-19)
|
||||
|
||||
**Recipients:** crystal.rodriguez@cascadestucson.com, lois.lane@cascadestucson.com, susan.hicks@cascadestucson.com
|
||||
|
||||
**From:** howard@azcomputerguru.com (or Arizona Computer Guru IT mailbox)
|
||||
|
||||
**Subject:** Heads-up: Fake "Recorded Message" email handled
|
||||
|
||||
---
|
||||
|
||||
Hi,
|
||||
|
||||
This is a quick heads-up: earlier today you each received an email titled **"Recorded Message playback"** that appeared to come from an internal address. It was a **phishing attempt from an external attacker** — not a real voicemail or internal message.
|
||||
|
||||
A few things to know:
|
||||
|
||||
- You do not need to do anything. We've already removed the message from your mailbox and blocked the sender at the email gateway.
|
||||
- Your account is not compromised. We reviewed your mailbox settings, sign-in history, and authentication methods — everything is clean.
|
||||
- If you clicked any link in that email, please let me know right away so we can check further.
|
||||
- The attacker used a slightly-misspelled display name ("Recoder") and a lookalike sender address. That's a common trick to make phishing look internal.
|
||||
|
||||
**Crystal** — great catch reporting this before clicking. That's exactly the right move. Everyone: when in doubt, forward to me (howard@azcomputerguru.com) and we'll verify before you open anything unusual.
|
||||
|
||||
Thanks,
|
||||
Howard Enos
|
||||
Arizona Computer Guru
|
||||
howard@azcomputerguru.com
|
||||
@@ -0,0 +1,169 @@
|
||||
# Crystal Rodriguez - Phishing Investigation ("Recoder" / Recorded Message playback)
|
||||
|
||||
**Date:** 2026-04-19
|
||||
**Tenant:** Cascades of Tucson (cascadestucson.com, `207fa277-e9d8-4eb7-ada1-1064d2221498`)
|
||||
**Subject:** crystal.rodriguez@cascadestucson.com (plus two secondary targets)
|
||||
**Tool:** Claude-MSP-Access / ComputerGuru - AI Remediation (App ID `fabb3421-8b34-484b-bc17-e46de9703418`)
|
||||
**Scope:** Investigation + remediation (see Remediation Actions section below).
|
||||
**Investigator:** Howard Enos
|
||||
|
||||
## Summary
|
||||
|
||||
- **Crystal Rodriguez is NOT compromised.** All 10 breach-check points are clean: no inbox rules, no forwarding, no OAuth consents, no delegates, no SendAs, no risky sign-ins, no suspicious sent/deleted items.
|
||||
- The email she reported is an **inbound phishing / voicemail-lure attack** from external IP `89.106.1.38` (Interserver VPS, Secaucus NJ, AS19318), spoofing internal lookalike From headers.
|
||||
- **Three Cascades staff received the same phishing blast** from the same attacker IP on 2026-04-19:
|
||||
- Crystal Rodriguez (2 copies, spoofed as `crystal.suszek@cascadestucson.com` - her old pre-marriage name)
|
||||
- Lois Lane (self-spoof)
|
||||
- Susan Hicks (self-spoof)
|
||||
- **All three mailboxes are clean.** No compromise of any recipient.
|
||||
- **Authentication failed on all three:** SPF=fail, DKIM=none, DMARC=fail. Microsoft EOP delivered them anyway because `compauth=pass reason=703` (composite-auth override). This indicates a tenant **anti-spoofing policy weakness** that should be tightened.
|
||||
- The attacker knew Crystal's old surname ("Suszek") - OSINT-driven social engineering, likely harvested from LinkedIn, prior directories, or pre-marriage contacts.
|
||||
|
||||
## Target details - crystal.rodriguez
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| UPN | crystal.rodriguez@cascadestucson.com |
|
||||
| Object ID | ac1799f6-b384-4afd-bbf9-223ea3d4fe79 |
|
||||
| Account Enabled | true |
|
||||
| Created | 2023-08-10 |
|
||||
| Last Password Change | 2025-11-13 |
|
||||
|
||||
## The phishing email
|
||||
|
||||
**Received:** 2026-04-19 06:09:42 UTC (Sat 4/18 11:09 PM MST) - then a second copy 13:39:33 UTC (Sun 4/19 6:39 AM MST)
|
||||
**From (header):** `"Recoder" <crystal.suszek@cascadestucson.com>` -- display name is a typo for "Recorder"; the address spoofs her old pre-marriage email (Suszek -> Rodriguez)
|
||||
**To:** `<crystal.suszek@cascadestucson.com>` (Crystal Rodriguez received via BCC-style delivery)
|
||||
**Subject:** Recorded Message playback
|
||||
**Sending IP:** 89.106.1.38 (Interserver, Inc / AS19318 / Secaucus NJ)
|
||||
|
||||
### Authentication results (from MIME header)
|
||||
|
||||
```
|
||||
Authentication-Results: spf=fail (sender IP is 89.106.1.38) smtp.mailfrom=cascadestucson.com;
|
||||
dkim=none (message not signed) header.d=none;
|
||||
dmarc=fail action=none header.from=cascadestucson.com;
|
||||
compauth=pass reason=703
|
||||
Received-SPF: Fail (protection.outlook.com: domain of cascadestucson.com does not
|
||||
designate 89.106.1.38 as permitted sender)
|
||||
```
|
||||
|
||||
**SPF fail + DKIM none + DMARC fail + compauth=pass=703** = the tenant's Anti-Spoofing / Anti-Phishing policy is **not enforcing a quarantine/reject action on authenticated-failure internal-lookalike spoofs.** EOP used heuristic "trust" to let it through.
|
||||
|
||||
## Per-check findings (Crystal Rodriguez)
|
||||
|
||||
| # | Check | Result |
|
||||
|---|---|---|
|
||||
| 1 | Inbox rules (Graph) | 0 |
|
||||
| 2 | Mailbox forwarding / auto-reply | disabled, none |
|
||||
| 3a | Hidden inbox rules (Exchange REST) | 3 - all benign system rules (Junk E-mail, OOF InternalSenders, OOF AllExternalSenders) |
|
||||
| 3b | Non-SELF mailbox permissions | 0 |
|
||||
| 3c | Non-SELF SendAs | 0 |
|
||||
| 3d | ForwardingAddress / ForwardingSmtpAddress | null / null |
|
||||
| 4a | OAuth permission grants | 0 |
|
||||
| 4b | App role assignments | 0 |
|
||||
| 5 | Authentication methods | 2 (no new methods in attack window) |
|
||||
| 6 | Interactive sign-ins (30d) | 0 (user likely on mobile/cached Outlook; no foreign or legacy auth) |
|
||||
| 7 | Directory audits (30d) | 2, both `Microsoft Substrate Management` (benign system) |
|
||||
| 8 | Risky user | `Forbidden` - app lacks `IdentityRiskyUser.Read.All` on this tenant (see Gaps) |
|
||||
| 9 | Sent items (25) | Normal business correspondence; one "FW: Recorded Message playback" to howard@azcomputerguru.com (user reporting to IT) |
|
||||
| 10 | Deleted items (25) | Newsletters/marketing + 1 "Undeliverable: Cascades of Tucson Respite Program" bounce; no deleted security alerts |
|
||||
|
||||
## Parallel checks - other recipients from same attacker IP
|
||||
|
||||
### Lois Lane (lois.lane@cascadestucson.com)
|
||||
- 0 Graph rules, 1 hidden rule (Junk E-mail benign), 0 forwarding, 0 OAuth, 0 delegates
|
||||
- 11 interactive sign-ins 30d, all US, no legacy-auth clients
|
||||
- **Clean**
|
||||
|
||||
### Susan Hicks (susan.hicks@cascadestucson.com)
|
||||
- 0 Graph rules, 3 hidden rules (Junk + OOF system rules, all benign), 0 forwarding, 0 OAuth, 0 delegates
|
||||
- 6 interactive sign-ins 30d, all US
|
||||
- Self-service password change on 2026-04-13 (predates attack by 5 days; self-initiated) + MFA device registration activity (benign)
|
||||
- **Clean**
|
||||
|
||||
## Suspicious items
|
||||
|
||||
- **Sending infrastructure:** 89.106.1.38 is a commodity VPS (Interserver). Block or throttle at tenant edge.
|
||||
- **Self-spoof pattern** ("from yourself to yourself" via From header): used against Lois and Susan. This is a classic voicemail-phish signature.
|
||||
- **Maiden-name spoof:** attacker specifically used `crystal.suszek` against Crystal Rodriguez. Non-trivial OSINT - the pre-marriage name isn't in the current GAL. Likely harvested from LinkedIn or an old mail list.
|
||||
|
||||
## Gaps - checks not completed
|
||||
|
||||
- **`riskyUsers` endpoint returned 403** for all three users. The Claude-MSP-Access app does not have `IdentityRiskyUser.Read.All` admin-consented on this tenant. To fix: grant consent at:
|
||||
`https://login.microsoftonline.com/207fa277-e9d8-4eb7-ada1-1064d2221498/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418`
|
||||
- **Non-interactive sign-ins (beta) returned 0** for Crystal. Could mean the scope is missing or she genuinely had none; lean toward missing visibility. Worth re-running once Identity Protection consent is granted.
|
||||
|
||||
## Next actions
|
||||
|
||||
1. **No account remediation required.** Do not force a password reset on Crystal Rodriguez - no compromise evidence. Forcing a reset on a non-breached user creates confusion and erodes trust. (If Crystal is already anxious, a voluntary password rotation is harmless but optional.)
|
||||
2. **Quarantine / purge the two delivered phishing copies** from Crystal's mailbox (IDs in `/tmp/remediation-tool/207fa277-e9d8-4eb7-ada1-1064d2221498/user-breach/crystal_rodriguez_cascadestucson_com/`) plus the single delivered copies in Lois Lane's and Susan Hicks's mailboxes. Use Purview Content Search / Compliance Search -> Purge.
|
||||
3. **Tighten anti-spoofing policy** in Microsoft Defender for Office 365:
|
||||
- Set Anti-Phishing policy to **Quarantine** (not Move to Junk) for spoofed internal senders
|
||||
- Enable **Impersonation protection** on cascadestucson.com domain
|
||||
- Consider blocking composite-auth `reason=703` overrides via mail-flow rule that junks messages where SPF=fail AND DMARC=fail AND the header-From is @cascadestucson.com
|
||||
4. **Block sender IP at tenant edge:** add 89.106.1.38 to the IP Allow/Block Lists -> Block List (Defender > Policies > Anti-spam > Connection filter policy).
|
||||
5. **User awareness ping:** short note to Crystal, Lois, and Susan confirming the email was fake, the IT team handled it, and no action required on their end. Reinforces the right reporting behavior Crystal already demonstrated (she asked before clicking - model behavior).
|
||||
6. **Grant Identity Protection consent** on the Claude-MSP-Access app (link above) so future investigations can see risky-user signals on this tenant.
|
||||
7. **Run a tenant-wide sweep** (`tenant-sweep.sh`) if concerned this attacker is probing beyond these three users. Recommended but not urgent given the tight clustering and clean mailboxes.
|
||||
|
||||
## Remediation actions
|
||||
|
||||
Executed by Howard Enos on 2026-04-19.
|
||||
|
||||
### 1. Permanent-deleted 4 phishing messages (Graph `POST /messages/{id}/permanentDelete`)
|
||||
|
||||
| Mailbox | Count | Result |
|
||||
|---|---|---|
|
||||
| crystal.rodriguez@cascadestucson.com | 2 | HTTP 204 (success) |
|
||||
| lois.lane@cascadestucson.com | 1 | HTTP 204 (success) |
|
||||
| susan.hicks@cascadestucson.com | 1 | HTTP 204 (success) |
|
||||
|
||||
Messages are hard-deleted (not recoverable via Deleted Items or Recoverable Items).
|
||||
|
||||
### 2. Blocked sender IP `89.106.1.38` in Connection Filter Policy
|
||||
|
||||
`Set-HostedConnectionFilterPolicy -Identity Default -IPBlockList @("89.106.1.38")`
|
||||
|
||||
Verified: `IPBlockList: ["89.106.1.38"]`. All future mail from this IP will be rejected at SMTP accept time, before content filtering runs.
|
||||
|
||||
### 3. Anti-Phishing policy tightening (PARTIAL - see gap below)
|
||||
|
||||
`Set-AntiPhishPolicy -Identity "Office365 AntiPhish Default" -AuthenticationFailAction Quarantine` **succeeded** (was `MoveToJmf`, now `Quarantine`).
|
||||
|
||||
`Set-AntiPhishPolicy -Identity "Standard Preset Security Policy..." -AuthenticationFailAction Quarantine` was **silently rejected** by EOP with warning: `"All recommended properties will be controlled by Microsoft."` Microsoft locks the Standard Preset's authentication-fail action at `MoveToJmf`.
|
||||
|
||||
**Impact:** Users covered by the Standard Preset (which is the active policy for this tenant) will continue to get spoofed-auth-fail messages dropped to Junk Folder, not Quarantine. The Default policy's Quarantine setting only applies to users not covered by any other policy. Mitigation options (none auto-executed — require portal action or user approval):
|
||||
|
||||
- **Option A (recommended):** Enable the built-in **Strict Preset Security Policy** in the Defender portal. The Strict preset enforces Quarantine for auth-fail spoofs. https://security.microsoft.com/presetSecurityPolicies
|
||||
- **Option B:** Create a new custom Anti-Phish policy with `AuthenticationFailAction: Quarantine` and assign a rule with priority above the Standard Preset. Carries some risk if the rule's user-scope is wrong.
|
||||
- **Option C:** Add a Mail Flow (Transport) Rule to quarantine messages where the header-From domain is `cascadestucson.com` AND the sending IP is outside the tenant's allowed egress. Verify first that no legitimate third-party service (marketing automation, forms, etc.) sends from `@cascadestucson.com`.
|
||||
|
||||
Note: the IP block (Action #2) defends against *this specific attacker* regardless of preset policy, so the immediate threat is contained.
|
||||
|
||||
### 4. User awareness email drafted
|
||||
|
||||
Text in `clients/cascades-tucson/docs/user-awareness-email-recoder-phish-2026-04-19.md`. Not auto-sent - send manually from howard@azcomputerguru.com to the three recipients.
|
||||
|
||||
### 5. Identity Protection scope consent - NOT executed (requires Global Admin in browser)
|
||||
|
||||
Click to grant consent for the Claude-MSP-Access app to read risky-user signals on future investigations:
|
||||
|
||||
https://login.microsoftonline.com/207fa277-e9d8-4eb7-ada1-1064d2221498/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418
|
||||
|
||||
### Audit artifacts
|
||||
|
||||
- Purge responses: `/tmp/remediation-tool/207fa277-e9d8-4eb7-ada1-1064d2221498/purge/permdel_*.json` (all HTTP 204)
|
||||
- Set-HostedConnectionFilterPolicy response: `/tmp/remediation-tool/207fa277-e9d8-4eb7-ada1-1064d2221498/remediate/connfilter_set.json`
|
||||
- Set-AntiPhishPolicy responses: `/tmp/remediation-tool/207fa277-e9d8-4eb7-ada1-1064d2221498/remediate/antiphish_*.json`
|
||||
|
||||
## Data artifacts
|
||||
|
||||
Raw JSON at `/tmp/remediation-tool/207fa277-e9d8-4eb7-ada1-1064d2221498/user-breach/`:
|
||||
- `crystal_rodriguez_cascadestucson_com/` - full 10-point breach check
|
||||
- `lois_lane_cascadestucson_com/` - full 10-point breach check
|
||||
- `susan_hicks_cascadestucson_com/` - full 10-point breach check
|
||||
|
||||
Phishing email MIME (for IOC extraction / reporting to Interserver abuse): `/tmp/recoder_mime.eml`
|
||||
|
||||
Message trace (V2) snapshot: `/tmp/mt_ip_wide.json`
|
||||
@@ -0,0 +1,114 @@
|
||||
# Cascades Tucson — Intune / MDM Prerequisites Gap Check
|
||||
|
||||
- **Date:** 2026-04-19 (UTC)
|
||||
- **Tenant:** `cascadestucson.com` (`207fa277-e9d8-4eb7-ada1-1064d2221498`)
|
||||
- **Intune account ID:** `15b6c28c-47fa-40b0-a6af-b9e03ab959c3`
|
||||
- **Method:** Read-only Graph API via Claude-MSP-Access app (no changes made)
|
||||
- **Goal:** establish what's configured vs. what needs setup before enrolling 25 shared Android phones (AESDM) + 9 kitchen iPads (Intune DEM)
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Prereq | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | **MDM authority = Intune** | Not set (`null`) | `intuneAccountId` is populated so Intune is provisioned. Just the authority flag hasn't been toggled. One-click in the admin center. One-time irreversible choice. |
|
||||
| 2 | **Apple MDM push certificate** | Not configured (`ResourceNotFound`) | Blocks ALL iPad/iOS management. Must generate at `identity.apple.com/pushcert` and upload. Annual renewal. |
|
||||
| 3 | **Apple Business Manager / DEP token** | 0 tokens | Optional for 9 iPads — manual enrollment works without it, but lose management on factory reset. Recommended. |
|
||||
| 3b | **Apple VPP token** | 0 tokens | Not required unless paid iPad apps need to be pushed. Skip for kitchen POS scenario. |
|
||||
| 4 | **Managed Google Play enterprise** | `bindStatus: notBound` / `enrollmentTarget: none` | Blocks ALL Android Enterprise management (AESDM, dedicated, work profile). One-click bind in the admin center, sign in with a Cascades Google account. |
|
||||
|
||||
## Existing Intune objects
|
||||
|
||||
| Object | Count | Notes |
|
||||
|---|---|---|
|
||||
| Compliance policies | 0 | Clean slate |
|
||||
| Device configuration profiles | 0 | Clean slate |
|
||||
| Enrollment configurations | 5 | All are Microsoft built-in defaults (device limit, platform restrictions, WHfB, Windows 10 ESP, Windows Restore). None custom. |
|
||||
| Android Device Owner enrollment profiles | 0 | None (need for AESDM / dedicated device mode) |
|
||||
| Managed devices | 0 | None enrolled |
|
||||
|
||||
---
|
||||
|
||||
## Gap list — what Howard needs to do before enrolling devices
|
||||
|
||||
### Must-do (blocking)
|
||||
|
||||
1. **Set MDM authority to Intune.**
|
||||
Admin center → *Tenant administration → Mobile Device Management authority* → pick **Microsoft Intune**. Takes 5 seconds. Cannot be undone. Is a hard prerequisite for every step below.
|
||||
|
||||
2. **Upload Apple MDM push certificate.**
|
||||
Required for any iPad management. Process:
|
||||
- Intune admin center → *Devices → iOS/iPadOS → Enrollment → Apple MDM Push Certificate*
|
||||
- Download CSR from Intune
|
||||
- Upload CSR at `identity.apple.com/pushcert` using a *dedicated* Apple ID (recommend `mdm-push@cascadestucson.com` so turnover doesn't break it — not Meredith's personal, not kitchenipads@)
|
||||
- Download the `.pem` cert from Apple, upload back into Intune
|
||||
- **Annual renewal** on the same Apple ID — if the Apple ID is lost, ALL enrolled iPads must be wiped and re-enrolled
|
||||
|
||||
3. **Bind Managed Google Play account.**
|
||||
Required for AESDM and any Android Enterprise policies.
|
||||
- Intune admin center → *Devices → Android → Android Enterprise → Managed Google Play*
|
||||
- Click *Launch Google* → sign in with a dedicated Google account (recommend `managedplay@cascadestucson.com`, NOT a personal Google account)
|
||||
- Accept terms, link → bindStatus becomes `bound`
|
||||
|
||||
### Should-do (strong recommendation)
|
||||
|
||||
4. **Create Apple Business Manager account and link to Intune.**
|
||||
- Sign up at `business.apple.com` (free, takes ~15 min + an Apple ID verification phone call for the D-U-N-S number)
|
||||
- Create an MDM server entry in ABM named "Intune — Cascades"
|
||||
- Download ABM token, upload to Intune at *Enrollment → Apple → Enrollment program tokens*
|
||||
- For existing iPads: add their serial numbers to ABM manually (one-time)
|
||||
- For future iPads: buy them through Apple or an ABM-linked reseller and they auto-appear
|
||||
|
||||
### Not needed for current scope
|
||||
|
||||
5. **Apple VPP token** — only needed if pushing paid apps. Kitchen POS app is probably free or sideloaded.
|
||||
6. **Windows Autopilot** — 89 Entra-registered Windows PCs exist, but Windows management is out of scope for this pass.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies (do in this order)
|
||||
|
||||
```
|
||||
Step 1 (MDM authority)
|
||||
│
|
||||
├── Step 3 (Google Play bind) ──► can enroll Android phones
|
||||
│
|
||||
├── Step 2 (Apple push cert) ──► can manually enroll iPads
|
||||
│ │
|
||||
│ └── Step 4 (ABM + token) ──► iPads auto-enroll + survive reset
|
||||
```
|
||||
|
||||
Steps 1 and 3 together unlock the 25 Android phones. Steps 1 + 2 together unlock the 9 iPads (with ABM as the recommended polish).
|
||||
|
||||
---
|
||||
|
||||
## Policy scaffold (to be built after prereqs)
|
||||
|
||||
For the Android phones (AESDM):
|
||||
- Enrollment profile: Android Enterprise *Corporate-owned, dedicated device*, mode = *AOSP multi-user* (this is AESDM) — generates a token/QR code for device enrollment
|
||||
- Compliance policy: encryption on, screen lock 4+ digits, 2-min timeout, no developer options, no debugging, Android 11+
|
||||
- Configuration profile: Wi-Fi (CSCNet), restrictions (no factory reset, no USB transfer, no unknown sources)
|
||||
- Apps: Company Portal (system), Authenticator, Edge, ALIS (as web app), Teams — required, auto-install
|
||||
|
||||
For the kitchen iPads (Intune DEM):
|
||||
- Create `dem-ipads@cascadestucson.com` user with Business Premium license assigned, mark as Device Enrollment Manager
|
||||
- Enrollment profile: iOS/iPadOS, device-without-user-affinity, kiosk mode
|
||||
- Compliance policy: passcode required, encryption on, no jailbreak
|
||||
- Configuration profile: Wi-Fi (CSCNet VLAN 20), Single App Mode locked to the kitchen POS app, disable App Store, disable Settings
|
||||
- Apps: kitchen POS app (required)
|
||||
|
||||
---
|
||||
|
||||
## Data sources
|
||||
|
||||
Raw Graph responses cached at `/tmp/remediation-tool/207fa277-e9d8-4eb7-ada1-1064d2221498/intune-prereqs/`:
|
||||
- `org.json` — organization object (MDM authority field)
|
||||
- `devicemgmt-beta.json` — deviceManagement root
|
||||
- `apple-push.json` — Apple push cert
|
||||
- `dep.json` — DEP tokens
|
||||
- `vpp.json` — VPP tokens
|
||||
- `gplay.json` — Managed Google Play binding
|
||||
- `compliance.json`, `configs.json`, `enrollment.json` — existing policies
|
||||
- `android-doe.json` — Android Device Owner enrollment profiles
|
||||
- `managed-devices.json` — enrolled device inventory
|
||||
693
clients/cascades-tucson/scripts/generate-user-questionnaire.py
Normal file
693
clients/cascades-tucson/scripts/generate-user-questionnaire.py
Normal file
@@ -0,0 +1,693 @@
|
||||
"""
|
||||
Generate one Cascades Tucson user-confirmation questionnaire per recipient
|
||||
(Meredith, Ashley, John). Output: three .docx files plus a shared .md source.
|
||||
|
||||
Format: Section 1 is the roster grouped by department with a [Keep] checkbox,
|
||||
editable name/title, and a Notes line for role description. Sections 2-7 are
|
||||
per-attribute checklists ("Who is a driver?", "Who uses ALIS?", etc.),
|
||||
organized by department so scanning is fast. Section 8 is free text.
|
||||
|
||||
Usage:
|
||||
python generate-user-questionnaire.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Inches, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from lxml import etree
|
||||
|
||||
OUT_DIR = Path(__file__).parent.parent / "docs" / "cloud" / "questionnaires"
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Word XML namespaces for content control checkboxes (clickable in Word)
|
||||
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml"
|
||||
|
||||
# Global counter for unique SDT IDs
|
||||
_sdt_id_counter = [100000]
|
||||
|
||||
|
||||
def _next_sdt_id():
|
||||
_sdt_id_counter[0] += 1
|
||||
return _sdt_id_counter[0]
|
||||
|
||||
|
||||
def add_clickable_checkbox(paragraph):
|
||||
"""
|
||||
Append a Word content-control checkbox to the given paragraph. Renders as
|
||||
an empty box that toggles to a checked box on click in Word 2010+, Word
|
||||
for the web, and Word mobile. Adds a trailing space run for readability.
|
||||
"""
|
||||
sdt_id = _next_sdt_id()
|
||||
xml = f'''<w:sdt xmlns:w="{W_NS}" xmlns:w14="{W14_NS}">
|
||||
<w:sdtPr>
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="MS Gothic" w:eastAsia="MS Gothic" w:hAnsi="MS Gothic" w:hint="eastAsia"/>
|
||||
</w:rPr>
|
||||
<w:id w:val="{sdt_id}"/>
|
||||
<w14:checkbox>
|
||||
<w14:checked w14:val="0"/>
|
||||
<w14:checkedState w14:val="2612" w14:font="MS Gothic"/>
|
||||
<w14:uncheckedState w14:val="2610" w14:font="MS Gothic"/>
|
||||
</w14:checkbox>
|
||||
</w:sdtPr>
|
||||
<w:sdtContent>
|
||||
<w:r>
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="MS Gothic" w:eastAsia="MS Gothic" w:hAnsi="MS Gothic" w:hint="eastAsia"/>
|
||||
</w:rPr>
|
||||
<w:t>\u2610</w:t>
|
||||
</w:r>
|
||||
</w:sdtContent>
|
||||
</w:sdt>'''
|
||||
sdt = etree.fromstring(xml)
|
||||
paragraph._p.append(sdt)
|
||||
# Trailing space after the checkbox so the label has breathing room
|
||||
space_run = paragraph.add_run(" ")
|
||||
return space_run
|
||||
|
||||
RECIPIENTS = [
|
||||
("Meredith Kuhn", "Executive Director"),
|
||||
("Ashley Jensen", "Assistant Executive Director"),
|
||||
("John Trozzi", "Maintenance Director"),
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Staff roster by department
|
||||
# Each entry: (display_name, title, notes_flag)
|
||||
# notes_flag text is prefilled into the Notes line when helpful
|
||||
# -----------------------------------------------------------------------------
|
||||
STAFF = {
|
||||
"Administrative": [
|
||||
("Meredith Kuhn", "Executive Director", ""),
|
||||
("Ashley Jensen", "Assistant Executive Director", ""),
|
||||
("Lauren Hasselman", "Business Office Director", ""),
|
||||
("Allison Reibschied", "Accounting Assistant", ""),
|
||||
],
|
||||
"Marketing / Sales": [
|
||||
("Megan Hiatt", "Sales Director", "Handles resident intake forms (PHI)"),
|
||||
("Crystal Rodriguez", "Sales Associate", "Handles resident intake forms (PHI)"),
|
||||
("Tamra Matthews", "Move-In Coordinator", "Leaving June 2026 — please confirm"),
|
||||
],
|
||||
"Care, Assisted Living (Nursing / Clinical)": [
|
||||
("Lois Lane", "Health Services Director", ""),
|
||||
("Karen Rossini", "Health Services Manager", ""),
|
||||
("Britney Thompson", "Memory Care Nurse", "Title says Memory Care but department currently Assisted Living — which is correct?"),
|
||||
("Veronica Feller", "Care, Assisted Living Aide", ""),
|
||||
],
|
||||
"Care, Memory Care": [
|
||||
("Shelby Trozzi", "Memory Care Director", ""),
|
||||
("Christine Nyanzunda", "Memory Care Admin Assistant", "Also appears on caregiver list (Sun/Mon AM) — same person, right?"),
|
||||
],
|
||||
"Resident Services": [
|
||||
("Christina DuPras", "Resident Services Director", ""),
|
||||
("Cathy Kingston", "Receptionist", "Front desk shared PC"),
|
||||
("Shontiel Nunn", "Receptionist", "Front desk shared PC"),
|
||||
("Kyla Quick Tiffany", "Receptionist", "NEW — not yet in AD. Spelling correct?"),
|
||||
("Michelle Shestko", "MC Receptionist", "MC front desk shared PC"),
|
||||
("Sebastian Leon", "Courtesy Patrol", ""),
|
||||
("Sheldon Gardfrey", "Courtesy Patrol", ""),
|
||||
("Ray Rai", "Courtesy Patrol", ""),
|
||||
],
|
||||
"Life Enrichment": [
|
||||
("Susan Hicks", "Life Enrichment Director", ""),
|
||||
("Sharon Edwards", "Life Enrichment Assistant", ""),
|
||||
],
|
||||
"Culinary": [
|
||||
("JD Martin", "Culinary Director", ""),
|
||||
("Ramon Castaneda", "Kitchen Manager", ""),
|
||||
("Alyssa Brooks", "Dining Manager", ""),
|
||||
],
|
||||
"Maintenance": [
|
||||
("John Trozzi", "Maintenance Director", ""),
|
||||
("Matt Brooks", "Memory Care Receptionist", "HR record shows department = Maintenance. Which is correct — Maintenance or Memory Care?"),
|
||||
],
|
||||
"Housekeeping": [
|
||||
("Lupe Sanchez", "Housekeeping Director", "AKA Guadalupe Sanchez"),
|
||||
],
|
||||
"Transportation": [
|
||||
("Richard Adams", "Driver", ""),
|
||||
("Julian Crim", "Driver", ""),
|
||||
("Christopher Holick", "Driver", ""),
|
||||
],
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Caregivers by shift pattern (39 total, none currently in AD/M365)
|
||||
# Each entry: (display_name, role, location, phone_number, flag_note)
|
||||
# -----------------------------------------------------------------------------
|
||||
CAREGIVERS = {
|
||||
"Tuesday-Saturday (15)": [
|
||||
("Thelma Abainza", "Caregiver", "Tower", "520-867-2579", ""),
|
||||
("Niel Castro", "MedTech / CCG", "Tower", "520-697-4644", ""),
|
||||
("Espe Esperance", "MedTech", "Tower", "520-788-9558", ""),
|
||||
("Barbara Johnson", "Caregiver", "Tower", "520-204-3449", ""),
|
||||
("Kasey Flores", "Caregiver", "Memory Care", "520-250-1451", ""),
|
||||
("Richard Flores", "Caregiver", "Memory Care", "520-873-7727", ""),
|
||||
("Marie Kastner", "Caregiver", "Memory Care", "714-576-9858", ""),
|
||||
("Bella Mendoza", "Caregiver", "Memory Care", "520-358-2000", ""),
|
||||
("Rosa Morales", "MedTech", "Memory Care", "312-213-8780", ""),
|
||||
("Sandra Padilla", "MedTech / CCG", "Tower", "520-585-3317", ""),
|
||||
("Polett Pinazavala", "MedTech", "Memory Care", "520-449-5533", "Please confirm spelling"),
|
||||
("Whisper Reed", "MedTech", "Tower (overnight)", "520-312-7575", ""),
|
||||
("Patricia Sandoval-Beck", "MedTech", "Tower", "520-343-8093", "Hyphenated last name correct?"),
|
||||
("Charity Sika", "Caregiver", "Memory Care", "623-251-8032", ""),
|
||||
("Ederick Yuzon", "Caregiver", "Tower", "520-603-8816", "Please confirm spelling"),
|
||||
],
|
||||
"Sunday-Thursday (10)": [
|
||||
("Juan Andrade", "Caregiver", "Memory Care", "520-528-4078", ""),
|
||||
("Jahmeka Clarke", "MedTech", "Memory Care", "520-649-7034", ""),
|
||||
("Karina Aziakpo", "MedTech / CCG", "Memory Care (overnight)", "520-392-6859", ""),
|
||||
("Jinnelle Dittbenner", "Caregiver", "Tower", "520-499-9996", ""),
|
||||
("Christine Nyanzunda (AM Sun/Mon only)", "MedTech", "Memory Care", "520-304-4251", "SAME person as the Memory Care Admin Assistant? (One account, not two)"),
|
||||
("Agnes McFerren", "Caregiver", "Tower", "520-406-3063", ""),
|
||||
("Samuel Ramirez", "Caregiver", "Tower", "520-488-5798", ""),
|
||||
("Erica Sanchez", "Caregiver", "Memory Care", "520-528-3387", ""),
|
||||
("Katrina Wyzykowski", "MedTech", "Memory Care", "520-347-1448", ""),
|
||||
("Corey Tate", "Caregiver (no MedTech)", "Tower (NOC)", "520-535-7821", ""),
|
||||
],
|
||||
"Friday-Monday / weekend (5)": [
|
||||
("Ashli Atwood", "MedTech / CCG", "Memory Care (overnight)", "715-200-1295", ""),
|
||||
("Cole Johnson", "MedTech", "Tower", "818-970-0890", ""),
|
||||
("Roseline Cooper", "Caregiver", "Memory Care (overnight)", "520-278-6817", ""),
|
||||
("Monique Lopez", "Caregiver (Fri+Sat doubles)", "Tower", "520-596-0969", ""),
|
||||
("Gloria Williford", "MedTech (Fri+Sat doubles)", "Memory Care", "928-551-1682", ""),
|
||||
],
|
||||
"Thursday-Monday (3)": [
|
||||
("Sarah Carroll", "Caregiver", "Tower", "520-409-2341", ""),
|
||||
("Luke Hogan", "Caregiver", "Tower", "520-312-0141", ""),
|
||||
("Gina Williams", "Caregiver", "Tower", "520-612-5075", ""),
|
||||
],
|
||||
"Split / partial-week (3)": [
|
||||
("Jen Higdon", "Caregiver", "Tower (M/W/F AM)", "520-730-3548", ""),
|
||||
("Mary Kariuki", "Caregiver", "Tower (Sat-Mon + Wed PM)", "520-309-1247", ""),
|
||||
("CeCe Lassey", "Caregiver", "Tower (Sun/Mon doubles + Tue PM)", "520-248-5982", ""),
|
||||
],
|
||||
"Sunday & Monday only (1)": [
|
||||
("Paty Doran", "MedTech / CCG", "Tower", "520-591-7368", "Paty, Patti, or Patricia? Please confirm"),
|
||||
],
|
||||
"PRN / float (2)": [
|
||||
("Ezekiel Huerta", "Caregiver (PRN)", "Tower", "520-591-6113", ""),
|
||||
("Maia Baker", "MedTech (PRN)", "Memory Care", "TBD", "On secondary sheet only — still employed?"),
|
||||
],
|
||||
}
|
||||
|
||||
# Flat list of ALL people, grouped by section, for the attribute checklists
|
||||
def all_people_by_group():
|
||||
groups = []
|
||||
for dept, people in STAFF.items():
|
||||
names = [p[0] for p in people]
|
||||
groups.append((dept, names))
|
||||
cg_all = []
|
||||
for _, people in CAREGIVERS.items():
|
||||
cg_all.extend(p[0] for p in people)
|
||||
groups.append(("Caregivers (all 39)", cg_all))
|
||||
return groups
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Document helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
def add_heading(doc, text, level=1):
|
||||
h = doc.add_heading(text, level=level)
|
||||
return h
|
||||
|
||||
|
||||
def add_para(doc, text, bold=False, size=11, italic=False, color=None):
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(text)
|
||||
run.font.size = Pt(size)
|
||||
run.bold = bold
|
||||
run.italic = italic
|
||||
if color:
|
||||
run.font.color.rgb = color
|
||||
return p
|
||||
|
||||
|
||||
def add_checkbox_line(doc, text, indent=0.25):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Inches(indent)
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
run = p.add_run(f"{CHECKBOX} {text}")
|
||||
run.font.size = Pt(11)
|
||||
return p
|
||||
|
||||
|
||||
def add_notes_line(doc, label="Notes: ", indent=0.5):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Inches(indent)
|
||||
p.paragraph_format.space_after = Pt(6)
|
||||
run = p.add_run(label + "_" * 80)
|
||||
run.font.size = Pt(10)
|
||||
run.font.color.rgb = RGBColor(0x60, 0x60, 0x60)
|
||||
return p
|
||||
|
||||
|
||||
def add_blank_line(doc):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(0)
|
||||
return p
|
||||
|
||||
|
||||
def add_separator(doc):
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run("_" * 90)
|
||||
run.font.color.rgb = RGBColor(0xCC, 0xCC, 0xCC)
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
|
||||
def add_attribute_question(doc, question_num, question_text, helper_text, include_caregivers=True):
|
||||
"""A question that lists names by department with a checkbox to mark yes."""
|
||||
add_heading(doc, f"{question_num}. {question_text}", level=2)
|
||||
if helper_text:
|
||||
add_para(doc, helper_text, italic=True, size=10, color=RGBColor(0x50, 0x50, 0x50))
|
||||
add_blank_line(doc)
|
||||
|
||||
for dept, people in STAFF.items():
|
||||
add_para(doc, dept, bold=True, size=11)
|
||||
for name, title, _ in people:
|
||||
add_checkbox_line(doc, f"{name} — {title}", indent=0.35)
|
||||
add_blank_line(doc)
|
||||
|
||||
if include_caregivers:
|
||||
add_para(doc, "Caregivers (none in M365 yet)", bold=True, size=11)
|
||||
for group_name, people in CAREGIVERS.items():
|
||||
add_para(doc, f" {group_name}", italic=True, size=10)
|
||||
for name, role, location, phone, _ in people:
|
||||
add_checkbox_line(doc, f"{name} — {role} ({location})", indent=0.5)
|
||||
add_blank_line(doc)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Document builder
|
||||
# -----------------------------------------------------------------------------
|
||||
def build_doc(recipient_name, recipient_title):
|
||||
doc = Document()
|
||||
|
||||
style = doc.styles["Normal"]
|
||||
style.font.name = "Calibri"
|
||||
style.font.size = Pt(11)
|
||||
|
||||
# ---- Title ----
|
||||
title = doc.add_heading("Cascades of Tucson — User + Access Confirmation", level=0)
|
||||
p = doc.add_paragraph()
|
||||
r = p.add_run(f"Prepared for: {recipient_name}, {recipient_title}")
|
||||
r.bold = True
|
||||
r.font.size = Pt(12)
|
||||
p = doc.add_paragraph()
|
||||
r = p.add_run("Prepared by: Howard Enos, Computer Guru \u2022 Date: 2026-04-18")
|
||||
r.font.size = Pt(10)
|
||||
r.italic = True
|
||||
|
||||
add_separator(doc)
|
||||
|
||||
# ---- Intro ----
|
||||
add_heading(doc, "Why we need this", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"Before we purchase the Microsoft 365 Business Premium licenses, create the 39 new "
|
||||
"caregiver accounts, and configure the security policies that lock down the shared "
|
||||
"phones and PHI access, I need to know four things about every person on your staff:",
|
||||
)
|
||||
add_checkbox_line(doc, "Is their name and department correct?", indent=0.3)
|
||||
add_checkbox_line(doc, "How do they use technology day-to-day (company phone, PC, both)?", indent=0.3)
|
||||
add_checkbox_line(doc, "Should they be allowed to sign in from outside the building?", indent=0.3)
|
||||
add_checkbox_line(doc, "Which systems / mailboxes / apps do they actually need?", indent=0.3)
|
||||
|
||||
add_para(
|
||||
doc,
|
||||
"Please answer the sections below at your own pace. Mark a box by double-clicking the "
|
||||
"\u2610 and replacing it with an X, or simply type an X in front of the name. Cross out "
|
||||
"anyone who should be removed. Write additions where it says \"Add anyone missing.\" "
|
||||
"Leave Comments / Notes where helpful \u2014 the more context, the better the lockdown.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_para(
|
||||
doc,
|
||||
"A quick note about ALIS: ALIS is the resident records website. When we tie it to "
|
||||
"Microsoft, users who log into their Microsoft account will automatically be signed "
|
||||
"into ALIS \u2014 no second username/password or second MFA prompt. That only works if "
|
||||
"we know who needs it (Section 5).",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
doc.add_page_break()
|
||||
|
||||
# ---- Section 1: Roster ----
|
||||
add_heading(doc, "Section 1 \u2014 The Staff Roster", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"Below is every person we currently have on file, organized by department. For "
|
||||
"each person: leave the Keep box checked if they are a current employee at that title, "
|
||||
"or uncheck and strike through anyone who should be removed. Correct spellings "
|
||||
"inline. Add a short note on the Notes line describing what they actually do day-to-day, "
|
||||
"especially anything involving resident information (PHI), medication, or outside-network "
|
||||
"access. Blank rows are at the bottom of each department for additions.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_blank_line(doc)
|
||||
|
||||
for dept, people in STAFF.items():
|
||||
add_heading(doc, dept, level=2)
|
||||
for name, title, flag in people:
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
p.paragraph_format.left_indent = Inches(0.25)
|
||||
r = p.add_run(f"{CHECKBOX} Keep ")
|
||||
r.bold = True
|
||||
r = p.add_run(f"{name} \u2014 {title}")
|
||||
if flag:
|
||||
p2 = doc.add_paragraph()
|
||||
p2.paragraph_format.left_indent = Inches(0.5)
|
||||
p2.paragraph_format.space_after = Pt(2)
|
||||
r = p2.add_run(f"\u203A {flag}")
|
||||
r.italic = True
|
||||
r.font.size = Pt(10)
|
||||
r.font.color.rgb = RGBColor(0xB0, 0x40, 0x00)
|
||||
add_notes_line(doc, " Notes: ")
|
||||
|
||||
# Three blank add-row lines
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Inches(0.25)
|
||||
r = p.add_run("Add anyone missing from this department:")
|
||||
r.italic = True
|
||||
r.font.size = Pt(10)
|
||||
r.font.color.rgb = RGBColor(0x40, 0x40, 0x80)
|
||||
for _ in range(3):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Inches(0.25)
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
r = p.add_run(f"{CHECKBOX} Add Name: ________________________ Title: ________________________")
|
||||
r.font.size = Pt(10)
|
||||
add_notes_line(doc, " Notes: ")
|
||||
add_blank_line(doc)
|
||||
|
||||
# ---- Caregivers section ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 1b \u2014 Caregivers (NEW \u2014 no current accounts)", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"These are the 39 staff currently using shared workstation logins. None has an email "
|
||||
"account or a Microsoft identity yet. They are the reason we are deploying the 25 "
|
||||
"shared Android phones. Please confirm each name + role, correct any spelling, and "
|
||||
"add anyone missing. Default assumption for every caregiver: phone-only, no outside "
|
||||
"access (locked to Cascades network + managed shared phone only). Flag exceptions "
|
||||
"in the Notes line.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_blank_line(doc)
|
||||
|
||||
for group_name, people in CAREGIVERS.items():
|
||||
add_heading(doc, group_name, level=2)
|
||||
for name, role, location, phone, flag in people:
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
p.paragraph_format.left_indent = Inches(0.25)
|
||||
r = p.add_run(f"{CHECKBOX} Keep ")
|
||||
r.bold = True
|
||||
r = p.add_run(f"{name} \u2014 {role} ({location}) [{phone}]")
|
||||
if flag:
|
||||
p2 = doc.add_paragraph()
|
||||
p2.paragraph_format.left_indent = Inches(0.5)
|
||||
p2.paragraph_format.space_after = Pt(2)
|
||||
r = p2.add_run(f"\u203A {flag}")
|
||||
r.italic = True
|
||||
r.font.size = Pt(10)
|
||||
r.font.color.rgb = RGBColor(0xB0, 0x40, 0x00)
|
||||
add_notes_line(doc, " Notes: ")
|
||||
add_blank_line(doc)
|
||||
|
||||
# Add-new-caregiver block
|
||||
p = doc.add_paragraph()
|
||||
r = p.add_run("Add any caregivers missing from this list:")
|
||||
r.italic = True
|
||||
r.font.size = Pt(10)
|
||||
r.font.color.rgb = RGBColor(0x40, 0x40, 0x80)
|
||||
for _ in range(5):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Inches(0.25)
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
r = p.add_run(f"{CHECKBOX} Add Name: _______________________ Role: _______________________ Phone: ______________")
|
||||
r.font.size = Pt(10)
|
||||
|
||||
# ---- Attribute-based questions ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 2 \u2014 Roles (check everyone who fits)", level=1)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"2a",
|
||||
"Who is a Driver? (Transportation / residents to appointments)",
|
||||
"Drivers typically use a company phone for dispatch and communication \u2014 usually no personal PC.",
|
||||
include_caregivers=False,
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"2b",
|
||||
"Who is a Nurse, MedTech, or Clinical Staff?",
|
||||
"People in this group see resident medical information (PHI) and are the main reason we care about HIPAA audit logs and ALIS single sign-on.",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"2c",
|
||||
"Who handles resident intake paperwork, leases, or medical forms by email?",
|
||||
"These people receive PHI via email from doctors, families, or facilities. They need the strongest anti-phishing and DLP (data loss prevention) policies.",
|
||||
include_caregivers=False,
|
||||
)
|
||||
|
||||
# ---- Section 3: Access questions ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 3 \u2014 How They Use Technology", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"For each person, please tell us whether they sit at a company PC, use a company "
|
||||
"phone on shift, or both.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"3a",
|
||||
"Who uses a COMPANY PC / DESKTOP?",
|
||||
"Anyone who signs into a Windows computer at Cascades. Includes shared front-desk PCs.",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"3b",
|
||||
"Who uses a COMPANY PHONE (the 25 shared Android phones)?",
|
||||
"Anyone who will pick up a shared phone at start of shift and use it for work (medication passes, resident records, email, rounds).",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"3c",
|
||||
"Who uses a COMPANY TABLET / iPad?",
|
||||
"Specifically the 9 kitchen iPads + any others. Likely Culinary staff.",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
# ---- Section 4: Outside access ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 4 \u2014 Sign-In From Outside the Building", level=1)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"4a",
|
||||
"Who should be allowed to sign in from OUTSIDE the building?",
|
||||
"Home office, travel, personal cell, hotel, etc. Anyone NOT checked will be locked to "
|
||||
"the Cascades network and managed devices only \u2014 which is what HIPAA and "
|
||||
"Conditional Access prefer by default.",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"4b",
|
||||
"Who should be allowed to check email on their PERSONAL cell phone?",
|
||||
"Separate question: some people may need outside access on a company laptop but NOT on a personal phone (HIPAA risk). Check only the people who genuinely need email on a personal device.",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
# ---- Section 5: ALIS ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 5 \u2014 ALIS (Resident Records)", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"ALIS is the resident records website. If we connect ALIS to Microsoft, users who "
|
||||
"sign into their Microsoft account will be automatically signed into ALIS \u2014 no "
|
||||
"second username / password, no second MFA prompt, no separate login. This is also "
|
||||
"where the per-person audit log lives (who opened what chart, when, from which device) "
|
||||
"which is the HIPAA story.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_para(
|
||||
doc,
|
||||
"We only want to connect ALIS for people who actually use it, so please check "
|
||||
"carefully.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"5a",
|
||||
"Who LOGS INTO ALIS today (resident charts, care plans, medication)?",
|
||||
"",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
add_attribute_question(
|
||||
doc,
|
||||
"5b",
|
||||
"Who should be able to access ALIS from OUTSIDE the building?",
|
||||
"Only matters for the people checked in 5a. Example: an on-call nurse charting from home.",
|
||||
include_caregivers=True,
|
||||
)
|
||||
|
||||
# ---- Section 6: Shared mailboxes ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 6 \u2014 Shared Mailboxes (Accounting@, Frontdesk@, etc.)", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"These are department mailboxes multiple people can access from their own sign-in. "
|
||||
"For each mailbox below, check everyone who should receive and send email through it. "
|
||||
"Leaving a mailbox blank means nobody needs it and we will decommission it.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_blank_line(doc)
|
||||
|
||||
shared_mailboxes = [
|
||||
("accounting@cascadestucson.com", "Accounting / AR / AP"),
|
||||
("accountingassistant@cascadestucson.com", "Accounting assistant desk"),
|
||||
("boadmin@cascadestucson.com", "Business Office admin"),
|
||||
("frontdesk@cascadestucson.com", "Main front desk (Tower)"),
|
||||
("memcarereceptionist@cascadestucson.com", "Memory Care front desk"),
|
||||
("hr@cascadestucson.com", "HR / employee matters"),
|
||||
("nurse@cascadestucson.com", "Clinical nursing desk"),
|
||||
("medtech@cascadestucson.com", "MedTech medication pass"),
|
||||
("transportation@cascadestucson.com", "Drivers / resident transport"),
|
||||
("security@cascadestucson.com", "Security / courtesy patrol"),
|
||||
("courtesypatrol@cascadestucson.com", "Courtesy Patrol (new \u2014 please confirm it\u2019s needed)"),
|
||||
("training@cascadestucson.com", "Training"),
|
||||
("sales@cascadestucson.com", "Sales inquiries"),
|
||||
("fax@cascadestucson.com", "Fax-to-email"),
|
||||
]
|
||||
for addr, desc in shared_mailboxes:
|
||||
add_heading(doc, f"{addr} \u2014 {desc}", level=3)
|
||||
for dept, people in STAFF.items():
|
||||
add_para(doc, dept, bold=True, size=10)
|
||||
for name, title, _ in people:
|
||||
add_checkbox_line(doc, f"{name} \u2014 {title}", indent=0.35)
|
||||
add_blank_line(doc)
|
||||
|
||||
# ---- Section 7: Odds and ends ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 7 \u2014 Specific Questions", level=1)
|
||||
|
||||
add_heading(doc, "7a. Departments that may need a re-label", level=2)
|
||||
add_para(doc, "Please mark the correct department for each:")
|
||||
add_checkbox_line(doc, "Britney Thompson \u2014 currently \"Care, Assisted Living,\" title says Memory Care Nurse. Correct department is:", indent=0.35)
|
||||
add_notes_line(doc, " ")
|
||||
add_checkbox_line(doc, "Matt Brooks \u2014 HR record says Maintenance, title says Memory Care Receptionist. Correct department is:", indent=0.35)
|
||||
add_notes_line(doc, " ")
|
||||
add_checkbox_line(doc, "Christine Nyanzunda \u2014 Memory Care Admin Assistant AND appears on caregiver shift list (Sun/Mon AM). One account or two?", indent=0.35)
|
||||
add_notes_line(doc, " ")
|
||||
|
||||
add_blank_line(doc)
|
||||
add_heading(doc, "7b. Spelling confirmations (caregivers)", level=2)
|
||||
for name, reason in [
|
||||
("Polett Pinazavala", "unusual spelling"),
|
||||
("Patricia Sandoval-Beck", "hyphenated last name"),
|
||||
("Ederick Yuzon", "unusual spelling"),
|
||||
("Paty Doran", "Paty / Patti / Patricia"),
|
||||
("Maia Baker", "only on secondary sheet \u2014 still employed?"),
|
||||
("Kyla Quick Tiffany", "three names or hyphenated?"),
|
||||
]:
|
||||
add_checkbox_line(doc, f"{name} \u2014 {reason} Correct spelling: ________________________", indent=0.35)
|
||||
|
||||
add_blank_line(doc)
|
||||
add_heading(doc, "7c. Departures / upcoming changes", level=2)
|
||||
add_para(doc, "Anyone leaving, transferring, or changing role in the next 90 days?", italic=True, size=10)
|
||||
for _ in range(5):
|
||||
add_notes_line(doc, "")
|
||||
|
||||
# ---- Section 8: Free text ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Section 8 \u2014 Anything Else", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
"Anything we should know about a specific person or situation? Unusual access needs, "
|
||||
"contractors, people who share a phone / PC, dual-role employees, anyone who handles "
|
||||
"legal or compliance records, etc. Write freely below.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_blank_line(doc)
|
||||
for _ in range(20):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
r = p.add_run("_" * 100)
|
||||
r.font.color.rgb = RGBColor(0xCC, 0xCC, 0xCC)
|
||||
|
||||
# ---- Closing ----
|
||||
doc.add_page_break()
|
||||
add_heading(doc, "Thank you", level=1)
|
||||
add_para(
|
||||
doc,
|
||||
f"{recipient_name.split()[0]} \u2014 thank you for taking the time on this. Once I have "
|
||||
"your answers (and Meredith\u2019s and Ashley\u2019s / John\u2019s), I will merge the "
|
||||
"three responses into one final list and come back with the exact license count, the "
|
||||
"exact accounts we are creating, and the exact Conditional Access rules before I change "
|
||||
"anything in your tenant. Nothing gets purchased or changed until you\u2019ve seen the "
|
||||
"final plan.",
|
||||
)
|
||||
add_para(
|
||||
doc,
|
||||
"Easiest way to return this: save and email back to howard@azcomputerguru.com. "
|
||||
"You can also drop a copy on our shared OneDrive or Teams folder and I\u2019ll pick it up.",
|
||||
italic=True,
|
||||
size=10,
|
||||
color=RGBColor(0x50, 0x50, 0x50),
|
||||
)
|
||||
add_para(
|
||||
doc,
|
||||
"\u2014 Howard",
|
||||
bold=True,
|
||||
)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generate files
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
for name, title in RECIPIENTS:
|
||||
doc = build_doc(name, title)
|
||||
first = name.split()[0]
|
||||
filename = OUT_DIR / f"cascades-user-access-questionnaire-{first}.docx"
|
||||
doc.save(str(filename))
|
||||
print(f"[OK] {filename}")
|
||||
print(f"\nAll files written to: {OUT_DIR}")
|
||||
152
clients/cascades-tucson/scripts/prep-profile-for-redirection.ps1
Normal file
152
clients/cascades-tucson/scripts/prep-profile-for-redirection.ps1
Normal file
@@ -0,0 +1,152 @@
|
||||
# Offline NTUSER.DAT prep for a ProfWiz-migrated Cascades user who is about
|
||||
# to log in for the first time since the Folder Redirection GPO was fixed.
|
||||
#
|
||||
# Combines two fixes that otherwise would require separate post-logon cleanup:
|
||||
#
|
||||
# 1. UNPOISON — any User Shell Folders values pointing at
|
||||
# C:\Windows\system32\config\systemprofile\... (a ProfWiz side effect)
|
||||
# get reset to %USERPROFILE% defaults so the Folder Redirection CSE
|
||||
# doesn't hang trying to access SYSTEM's profile.
|
||||
#
|
||||
# 2. PRE-SEED UNC TARGETS — the legacy name (Personal) AND the modern
|
||||
# KnownFolder GUID forms for Documents and Downloads get set to the
|
||||
# UNC redirect path in advance, so the Explorer sidebar resolves
|
||||
# correctly on first login (no more "this file has no associated app"
|
||||
# post-login patch like we needed for Sharon Edwards).
|
||||
#
|
||||
# WHEN TO USE
|
||||
# Run BEFORE the user's first login after the Folder Redirection GPO has
|
||||
# been linked to their OU. Requires the user to be logged OFF so
|
||||
# NTUSER.DAT can be loaded as a hive.
|
||||
#
|
||||
# HOW TO RUN
|
||||
# ScreenConnect Backstage PowerShell (runs as SYSTEM) is the reliable path.
|
||||
# Example for Susan Hicks on DESKTOP-ROK7VNM:
|
||||
#
|
||||
# .\prep-profile-for-redirection.ps1 `
|
||||
# -ProfilePath 'C:\Users\Susan.Hicks' `
|
||||
# -RedirectionRoot '\\CS-SERVER\homes' `
|
||||
# -SamName 'Susan.Hicks'
|
||||
#
|
||||
# SAFETY
|
||||
# - Idempotent: running twice is a no-op. Re-runs only log [KEEP].
|
||||
# - Never touches Desktop, Music, Pictures, Video, Favorites UNC paths
|
||||
# (only resets them to %USERPROFILE% defaults IF they're poisoned).
|
||||
# - Timestamped NTUSER.DAT backup at C:\ProfileBackups\ before any write.
|
||||
#
|
||||
# ROLLBACK
|
||||
# Copy-Item <backup> <ntuser-path> -Force (while user logged off)
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProfilePath, # e.g. 'C:\Users\Susan.Hicks'
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RedirectionRoot, # e.g. '\\CS-SERVER\homes'
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SamName, # e.g. 'Susan.Hicks'
|
||||
|
||||
[string]$BackupDir = 'C:\ProfileBackups',
|
||||
[string]$TempHiveName = 'ProfileFix'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ntuser = Join-Path $ProfilePath 'NTUSER.DAT'
|
||||
if (-not (Test-Path $ntuser)) { throw "NTUSER.DAT not found at $ntuser" }
|
||||
|
||||
# Target UNC paths
|
||||
$uncBase = "$RedirectionRoot\$SamName"
|
||||
$docsUNC = "$uncBase\Documents"
|
||||
$dlUNC = "$uncBase\Downloads"
|
||||
|
||||
# Backup
|
||||
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$leaf = Split-Path $ProfilePath -Leaf
|
||||
$backup = Join-Path $BackupDir "$leaf-NTUSER.DAT.$stamp.bak"
|
||||
Copy-Item $ntuser $backup -Force
|
||||
Write-Host "[OK] Backup -> $backup"
|
||||
|
||||
# Unload stale mount if any, then load hive
|
||||
if (Test-Path "Registry::HKEY_USERS\$TempHiveName") {
|
||||
reg unload "HKU\$TempHiveName" 2>&1 | Out-Null
|
||||
Start-Sleep 1
|
||||
}
|
||||
$loadResult = reg load "HKU\$TempHiveName" $ntuser 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "reg load failed: $loadResult" }
|
||||
Write-Host "[OK] Hive loaded at HKU\$TempHiveName"
|
||||
|
||||
try {
|
||||
$USF = "Registry::HKEY_USERS\$TempHiveName\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders"
|
||||
$SF = "Registry::HKEY_USERS\$TempHiveName\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
|
||||
|
||||
# --- Part 1: pre-seed UNC targets (legacy + GUID form, both hives) ---
|
||||
$targets = [ordered]@{
|
||||
'Personal' = $docsUNC # legacy name for Documents
|
||||
'{FDD39AD0-238F-46AF-ADB4-6C85480369C7}' = $docsUNC # modern Documents KF GUID
|
||||
'{374DE290-123F-4565-9164-39C4925E467B}' = $dlUNC # modern Downloads KF GUID
|
||||
}
|
||||
|
||||
Write-Host "`n--- Pre-seeding redirect targets ---"
|
||||
foreach ($name in $targets.Keys) {
|
||||
$new = $targets[$name]
|
||||
$current = (Get-ItemProperty -Path $USF -Name $name -ErrorAction SilentlyContinue).$name
|
||||
|
||||
if ($current -eq $new) {
|
||||
Write-Host " [KEEP] $name = '$current' (already correct)"
|
||||
} elseif ($null -eq $current) {
|
||||
New-ItemProperty -Path $USF -Name $name -Value $new -PropertyType ExpandString -Force | Out-Null
|
||||
Write-Host " [ADDED] $name = $new"
|
||||
} else {
|
||||
Set-ItemProperty -Path $USF -Name $name -Value $new -Type ExpandString
|
||||
Write-Host " [CHANGED] $name : '$current' -> '$new'"
|
||||
}
|
||||
|
||||
# Mirror into Shell Folders (REG_SZ, resolved path)
|
||||
New-ItemProperty -Path $SF -Name $name -Value $new -PropertyType String -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
|
||||
# Legacy "My Documents" entry some older apps read
|
||||
New-ItemProperty -Path $SF -Name 'My Documents' -Value $docsUNC -PropertyType String -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
# --- Part 2: unpoison anything still pointing at systemprofile ---
|
||||
$unpoison = [ordered]@{
|
||||
'My Music' = '%USERPROFILE%\Music'
|
||||
'My Pictures' = '%USERPROFILE%\Pictures'
|
||||
'My Video' = '%USERPROFILE%\Videos'
|
||||
'Favorites' = '%USERPROFILE%\Favorites'
|
||||
}
|
||||
$poisonPrefix = 'C:\Windows\system32\config\systemprofile'
|
||||
|
||||
Write-Host "`n--- Unpoisoning non-redirected shell folders ---"
|
||||
foreach ($name in $unpoison.Keys) {
|
||||
$new = $unpoison[$name]
|
||||
$current = (Get-ItemProperty -Path $USF -Name $name -ErrorAction SilentlyContinue).$name
|
||||
|
||||
if ($null -eq $current) {
|
||||
New-ItemProperty -Path $USF -Name $name -Value $new -PropertyType ExpandString -Force | Out-Null
|
||||
Write-Host " [ADDED] $name = $new"
|
||||
} elseif ($current -like "$poisonPrefix*") {
|
||||
Set-ItemProperty -Path $USF -Name $name -Value $new -Type ExpandString
|
||||
Write-Host " [CHANGED] $name : '$current' -> '$new' (was poisoned)"
|
||||
} else {
|
||||
Write-Host " [KEEP] $name = '$current' (not poisoned)"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
# Release any handles so the hive can unload cleanly
|
||||
[gc]::Collect()
|
||||
Start-Sleep 2
|
||||
reg unload "HKU\$TempHiveName" 2>&1 | Out-Null
|
||||
Write-Host "[OK] Hive unloaded"
|
||||
}
|
||||
|
||||
Write-Host "`nBackup: $backup"
|
||||
Write-Host "Rollback: Copy-Item '$backup' '$ntuser' -Force (while user logged off)"
|
||||
Write-Host ""
|
||||
Write-Host "Next step: have the user sign in. The Folder Redirection CSE will apply the"
|
||||
Write-Host "GPO (no hang because hive is clean) and the sidebar will resolve correctly"
|
||||
Write-Host "because the GUID-form shell folders already point at the UNC target."
|
||||
Reference in New Issue
Block a user