Use a Playwright action to sign in to a SaaS admin portal with no API and report its users, seats and suspended accounts still holding a licence
Find out who has an account on a vendor's admin portal and how many of the seats you pay for are in use, for a SaaS product that has no API, such as a phone system, a line-of-business app or a password manager. A Playwright workflow action signs in, reads the Users and Licences pages, and returns the seat counts, the user list and any suspended users still holding a seat. For MSPs and IT teams who check seat counts and leavers' accounts by hand before a renewal or an offboarding.
| Runs in | Cloud sandbox (Playwright) |
| Uses | API key credential (portal username and password stored as custom headers) |
| Suggested approval | None (engineers and admins run it without approval), because it only reads |
| Category | Browser automation |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and it hasn't been tested end to end. The portal address, page paths, labels and table columns are placeholders: adjust them for each portal. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, and adjust it together. Then test it, as in Test it. Test in the action editor isn't a dry run: it runs the script for real.
Why use an action instead of asking SuperIT
SuperIT has no web browser. It can search the web, but it can't sign in to a vendor's admin portal and read who has an account there. A Playwright action does that headlessly in the cloud sandbox, with the portal login stored as an encrypted credential, and returns the users and seat counts so SuperIT can answer from them. For Microsoft 365 licences and users, just ask SuperIT in chat instead; it reads them through the Microsoft 365 connection. If the vendor has an API, use Call a third-party API instead.
When to use it
- "How many phone system seats are we paying for, and how many are in use?"
- "Does Jo Smith still have an account on the vendor portal? She left last week."
- "Before the renewal, which suspended users are still holding a seat?"
What the action does
- Opens a headless Chromium browser in the cloud sandbox.
- Signs in to the portal with the username and password from the linked credential. If the credential also holds an authenticator (TOTP) setup key, it enters the six-digit code when the portal asks for one.
- Reads the Licences page: purchased, assigned and available seats for each product.
- Reads the Users page, following the Next button for up to 20 pages. After each Next, it waits until the table shows different rows before reading, and stops if they don't change, so no page is counted twice.
- If you give a
user, keeps only the users whose name or email contains it. - Flags users whose status is suspended, disabled or inactive but who still hold a licence.
- Prints a summary and returns the seats, users and flagged users as a structured result. If a seat count can't be read as a number, it shows as unknown and the summary warns you, rather than counting it as 0.
What it doesn't do:
- It doesn't change anything in the portal. It doesn't remove users or free seats; do that in the portal, or build a separate action with an approval, as in Fill in a web form on a vendor portal.
- It doesn't compare the portal's users with Microsoft 365. Ask SuperIT in chat to check the returned names against Microsoft 365.
- It doesn't complete push-notification or SMS sign-in challenges. Only an authenticator-app (TOTP) code works.
- It doesn't run on a device. Playwright actions run in the cloud sandbox only.
Before you begin
- A portal account for automation, ideally a read-only admin role used only by SuperIT, so you can see its activity and revoke it without affecting a person.
-
Multi-factor authentication. If the portal requires it, choose one:
- Add the account's authenticator-app (TOTP) setup key to the credential as a
totp_seedrow. The script then generates the code itself. Storing the seed means SuperIT holds both factors for that account, so keep the account's rights to read-only. - Use a service account the vendor allows to sign in without MFA, for example from known IP addresses only. SuperIT's sandbox uses fixed outbound IP addresses (see Network).
Which options you have depends on the vendor. Check that your own security policy allows the one you choose before you use it. - A credential. Under Workflows → Credentials, select New, name it
Vendor Portaland choose Custom HTTP headers (under Advanced). Add rowsusernameandpassword, plustotp_seedif you use an authenticator code. The script reads them asCREDENTIAL_VENDOR_PORTAL_USERNAME,CREDENTIAL_VENDOR_PORTAL_PASSWORDandCREDENTIAL_VENDOR_PORTAL_TOTP_SEED. Values are encrypted at rest. If you already createdVendor Portalfor another portal, give this one its own name, such asPhone Portal, and change the variable names in the script to match. See Credentials. - Check the portal's terms allow automated access. - Your role. Creating an action in chat needs the Admin role. Engineers can build it by hand under Workflows → Actions. See Approvals for who can run it. - Add the account's authenticator-app (TOTP) setup key to the credential as a
Build it with SuperIT
Describe the pages and what you see on them. SuperIT turns that into a Playwright script:
Create an action called "Check vendor portal users and licences".
Purpose: sign in to https://portal.vendor.example, read the licence counts and the user list, and flag suspended users who still hold a licence. Read-only.
Runtime: Playwright/Python in the cloud sandbox.
Input: user (string, optional): only report users whose name or email contains this.
Credential: "Vendor Portal" (custom headers username, password and optional totp_seed).
Sign-in page: fields "Email" and "Password", button "Sign in". If totp_seed is set and the portal asks for a code, enter the 6-digit code in "Verification code" and click "Verify"; if it goes straight to the signed-in page, carry on.
Licences page /admin/licences: table with columns Product, Purchased, Assigned.
Users page /admin/users: table with columns Name, Email, Role, Licence, Status, Last sign-in; a "Next" button for more pages.
Never print the password or the code.
No authorization policy: engineers and admins only, no approval.
SuperIT drafts the script, validates it and creates the action as a draft. Portals differ, so expect to adjust the labels, paths and column names after the first test. You review it, test it and publish it under Workflows → Actions.
Or build it yourself
| Name | Type | Required | Example |
|---|---|---|---|
user |
string | No | jo.smith |
Set Runtime to Playwright/Python. SuperIT starts the browser for you and provides page, browser_context and input_context. Use await at the top level, and assign output to return a result. As in SuperIT's own scripts, main comes first and reads as the plan, each step is a small function below it, and the last line runs it.
Everything specific to the portal is at the top of the script: the address, the paths, the on-screen labels and the column names. Change those to match your portal.
# Goal: sign in to the vendor portal, read its licence counts and user list, and flag
# suspended users who still hold a licence. Read-only: it changes nothing in the portal.
import asyncio
import base64
import hashlib
import hmac
import os
import struct
import time
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
# Placeholders: replace with your portal's address, paths, labels and column names.
PORTAL = "https://portal.vendor.example"
LOGIN_PATH = "/login"
HOME_URL_PATTERN = "**/admin/**"
LICENCES_PATH = "/admin/licences"
USERS_PATH = "/admin/users"
LABELS = {"email": "Email", "password": "Password", "sign_in": "Sign in",
"code": "Verification code", "verify": "Verify", "next": "Next"}
COLUMNS = {"product": "Product", "purchased": "Purchased", "assigned": "Assigned",
"name": "Name", "email": "Email", "role": "Role", "licence": "Licence",
"status": "Status", "last_sign_in": "Last sign-in"}
INACTIVE_STATUSES = {"suspended", "disabled", "inactive", "blocked"}
MAX_PAGES = 20
async def main(user_filter):
username, password, totp_seed = read_credential()
await sign_in(username, password, totp_seed)
licences = await read_licences()
users = filter_users(await read_users(), user_filter)
flagged = find_inactive_with_licence(users)
summary = {"licences": licences, "user_filter": user_filter or None,
"user_count": len(users), "users": users, "inactive_with_licence": flagged}
print_summary(summary)
return summary
def read_credential():
username = os.environ.get("CREDENTIAL_VENDOR_PORTAL_USERNAME")
password = os.environ.get("CREDENTIAL_VENDOR_PORTAL_PASSWORD")
totp_seed = os.environ.get("CREDENTIAL_VENDOR_PORTAL_TOTP_SEED")
if not username or not password:
raise RuntimeError("The 'Vendor Portal' credential is missing username or password.")
return username, password, totp_seed
async def sign_in(username, password, totp_seed):
await page.goto(PORTAL + LOGIN_PATH, wait_until="domcontentloaded")
await page.get_by_label(LABELS["email"]).fill(username)
await page.get_by_label(LABELS["password"]).fill(password)
await page.get_by_role("button", name=LABELS["sign_in"]).click()
if totp_seed and await code_requested():
await enter_totp_code(totp_seed)
try:
await page.wait_for_url(HOME_URL_PATTERN, timeout=30000)
except PlaywrightTimeoutError:
raise RuntimeError("Sign-in didn't reach the admin pages. Check the credential, "
"or whether the portal asked for MFA the script can't answer.") from None
print("Signed in.")
async def code_requested():
# Portals skip the code for a trusted sign-in, so wait for whichever comes first: the code box or the signed-in page.
waits = [asyncio.ensure_future(page.get_by_label(LABELS["code"]).wait_for(timeout=30000)),
asyncio.ensure_future(page.wait_for_url(HOME_URL_PATTERN, timeout=30000))]
done, pending = await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
# A timeout here isn't fatal: sign_in's own wait for the signed-in page reports it.
failed = {task for task in done if task.exception() is not None}
return waits[0] in done and waits[0] not in failed
async def enter_totp_code(totp_seed):
code_box = page.get_by_label(LABELS["code"])
await code_box.fill(totp_code(totp_seed))
await page.get_by_role("button", name=LABELS["verify"]).click()
print("Authenticator code entered.")
def totp_code(seed):
seed = seed.replace(" ", "").upper()
key = base64.b32decode(seed + "=" * (-len(seed) % 8))
digest = hmac.new(key, struct.pack(">Q", int(time.time()) // 30), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
return f"{(struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1000000:06d}"
async def read_licences():
table = await open_table(LICENCES_PATH)
licences = []
for row in await read_table(table):
product = row.get(COLUMNS["product"])
purchased = to_int(row.get(COLUMNS["purchased"]), product)
assigned = to_int(row.get(COLUMNS["assigned"]), product)
available = purchased - assigned if purchased is not None and assigned is not None else None
licences.append({"product": product, "purchased": purchased,
"assigned": assigned, "available": available})
print(f"Read {len(licences)} licence lines.")
return licences
async def read_users():
table = await open_table(USERS_PATH)
users, seen = [], set()
for _ in range(MAX_PAGES):
for user in (to_user(row) for row in await read_table(table)):
key = (user["email"] or user["name"]).lower()
if key not in seen:
seen.add(key)
users.append(user)
next_button = page.get_by_role("button", name=LABELS["next"]).first
if await next_button.count() == 0 or not await next_button.is_enabled():
break
previous_first_row = await first_row_text(table)
await next_button.click()
if not await wait_for_new_rows(table, previous_first_row):
print("The list didn't change after Next; stopped so no page is counted twice.")
break
else:
print(f"Stopped after {MAX_PAGES} pages; the list may be incomplete.")
print(f"Read {len(users)} users.")
return users
async def open_table(path):
# Wait for the rows themselves: "networkidle" can time out on portals that keep polling.
await page.goto(PORTAL + path, wait_until="domcontentloaded")
table = page.locator("table").first
try:
await table.locator("tbody tr").first.wait_for(timeout=30000)
except PlaywrightTimeoutError:
raise RuntimeError(f"No table rows appeared on {path}. Check the path and that the account can see it.") from None
return table
async def first_row_text(table):
rows = table.locator("tbody tr")
return (await rows.first.inner_text()).strip() if await rows.count() else ""
async def wait_for_new_rows(table, previous_first_row):
# Client-side paging doesn't reload the page, so wait until the first row changes.
for _ in range(30):
await page.wait_for_timeout(500)
current = await first_row_text(table)
if current and current != previous_first_row:
return True
return False
async def read_table(table):
headers = [h.strip() for h in await table.locator("thead th").all_inner_texts()]
rows = []
for row in await table.locator("tbody tr").all():
cells = [c.strip() for c in await row.locator("td").all_inner_texts()]
rows.append(dict(zip(headers, cells)))
return rows
def to_user(row):
return {key: row.get(COLUMNS[key], "") for key in
("name", "email", "role", "licence", "status", "last_sign_in")}
def filter_users(users, user_filter):
if not user_filter:
return users
needle = user_filter.lower()
return [u for u in users if needle in u["name"].lower() or needle in u["email"].lower()]
def find_inactive_with_licence(users):
return [u for u in users
if u["status"].lower() in INACTIVE_STATUSES and u["licence"] not in ("", "-", "None")]
def to_int(value, product):
try:
return int(str(value).replace(",", "").strip())
except ValueError:
print(f"WARNING: couldn't read '{value}' as a number for {product}; shown as unknown.")
return None
def print_summary(summary):
print("=== VENDOR PORTAL USERS AND LICENCES ===")
for lic in summary["licences"]:
shown = {k: ("unknown" if lic[k] is None else lic[k]) for k in ("assigned", "purchased", "available")}
print(f"{lic['product']} : {shown['assigned']} of {shown['purchased']} assigned, {shown['available']} available")
print(f"Users : {summary['user_count']}" + (f" matching '{summary['user_filter']}'" if summary["user_filter"] else ""))
for u in summary["users"][:50]:
print(f"- {u['name']} <{u['email']}> | {u['role']} | {u['licence']} | {u['status']} | last sign-in {u['last_sign_in']}")
if summary["user_count"] > 50:
print(f"... and {summary['user_count'] - 50} more in the result.")
print(f"Inactive with a licence : {len(summary['inactive_with_licence'])}")
for u in summary["inactive_with_licence"]:
print(f"- {u['name']} <{u['email']}>: {u['status']}, holds {u['licence']}")
output = await main((input_context.get("user") or "").strip())
Tips for portal scripts:
- Prefer
get_by_labelandget_by_rolewith the text you see on screen. They survive layout changes better than CSS selectors. - If the page has more than one table, replace
page.locator("table").firstwith something more specific, such aspage.get_by_role("table", name="Users"). - If the portal's tables are built from
divelements rather thantable,read_tablefinds nothing. Read rows withpage.get_by_role("row")instead. - The script prints after each stage ("Signed in.", "Read 42 users."), so a failed run shows how far it got. It never prints the password or the authenticator code.
- A long user list can take more than the default 5-minute Script timeout (minutes). Raise it on the Script tab if needed.
Test it
- Save the draft and select Test.
- Test is not a dry run: it skips authorization and approvals and uses the real credential and network access, so it really signs in to the portal. The action only reads. Test runs aren't billed.
- Compare the seat counts and the number of users with what the portal shows. With more than one page of users, check the total matches the portal's count and no user appears twice.
- Run it again with
userset to one person, and check you get just that person. - If it fails, read Stdout for the last stage reached and Stderr for the error, adjust the labels, paths or column names, and select Run again.
- Select Publish.
Use it
- In chat: "Does Jo Smith still have a phone system account?" SuperIT runs the action with
userset tojo smithand tells you what it found. Then ask SuperIT to check the same names in Microsoft 365, which it can read itself. - Before a renewal: "How many seats are we using on the phone system, and who's suspended but still licensed?"
- Offboarding: run it with
userset to the leaver after removing their account, and link the run to the offboarding ticket so completion and error notes post to the PSA ticket. - On a schedule: ask SuperIT to schedule it, for example every 30 days. A schedule doesn't send the output anywhere, so add a step that posts the summary, as in Send a weekly summary report.
- Managed teams: if each customer has its own portal login, use Publish to teams. Each managed team gets a read-only copy and a blank
Vendor Portalcredential for its admin to fill in.
Variations
- Export instead of reading the screen: if the portal has an Export users button, download the CSV as in Download a report from a vendor portal. It's faster than paging through a long list.
- Unused seats: flag users whose last sign-in is older than 90 days, so you can reclaim the seat before the renewal.
- Admin review: return only users whose role is an administrator role, for a quarterly access review.