Team & Resellers
Obsidian ships with a four-tier role model so a single owner can delegate day-to-day work without handing over the whole panel. Every account lives in the sellers collection with a role field and a parent_id that forms a strict tree.
Roles
There are four roles. Three of them are stored in the database (owner, admin, reseller); the fourth is a runtime override tied to the DEVELOPER_EMAILS environment variable.
The owner sits at the top and cannot be modified, demoted, disabled, or deleted through the API. _protect_owner in team.py blocks any such request with HTTP 403. Only the owner can promote a member to admin.
Admins own their own subtree of resellers and can also own applications, subject to subscription gating. They can create resellers but not other admins. Any attempt to reach outside their subtree returns HTTP 403, and their role, enabled flag, and allowed_apps stay locked to the owner.
Resellers manage nothing else in the panel. Their key generation is capped by key_quota (labelled "Credits" in the UI), and everything else they can see is filtered by the per-reseller permission matrix below.
Developers are not stored anywhere. Any account whose email appears in the comma-separated DEVELOPER_EMAILS env var passes is_developer() and is treated as a super-user across every permission check. POST /api/team and PATCH /api/team/{id} both reject those emails with Email address is reserved.
Deleting an account re-parents its children up to the actor. DELETE /api/team/{id} runs sellers.update_many({parent_id: member_id}, {parent_id: actor.id}) before removing the row, so the tree never becomes orphaned.
The Users panel
/users is the entry point. /team still redirects to it via a <Navigate replace> in App.js, and the page title is always "Reseller Accounts" regardless of role. Resellers never see this page at all, because GET /api/team returns an empty list for them.
The table shows one row per direct or transitive report. Columns are Name, Email, Role, Apps (count of allowed_apps), Credits (reseller only), Status, and Joined, and every header sorts.
Counter chips at the top of the page tally total, admin, reseller, disabled, and with-apps rows off the raw member list, so the numbers stay stable while you filter.
Three action icons sit at the end of each row:
- Gear opens the Settings dialog (role, apps, credits, permissions, subscription, status).
- Lock opens the Security dialog (email, password).
- Trash opens the delete-confirmation modal.
Enable and disable is done from the Status buttons inside the Settings dialog, not from a per-row shortcut on the table itself.
Search, filter, bulk actions
Above the table you get a search box that matches name or email, plus three dropdowns: role (all/admin/reseller), status (all/enabled/disabled), and apps (any/with/without). All four filter in place and combine with the current sort.
Selecting one or more rows opens a floating action bar with five operations: add credits (with a number input, resellers only), Enable, Disable, Revoke apps, and Delete. Each action loops through the selection one row at a time and reports how many succeeded and how many failed.
Bulk Revoke apps on an admin cascades to every reseller under that admin and kills all of their sessions. _cascade_app_revocation() walks each child, drops the removed app ids from their allowed_apps, and calls revoke_all_user_sessions.
Invite flow
Click New Admin or New Reseller (the label follows the role selector; admins only ever see "New Reseller" because they cannot create admins) to open the create dialog. Submitting the form posts to POST /api/team with a TeamCreate body.
The dialog collects username (3-32 chars, [a-zA-Z0-9_-], unique across all sellers), display name, password (min 8, enforce_password_policy also runs server-side), and application access. For resellers it also collects Key Quota (0 = unlimited, sent as key_quota), free-form notes (max 500 chars), and the eleven-key permission matrix.
Email is not collected here. New accounts land in the database without an email and you set one afterwards from the Security dialog. DEVELOPER_EMAILS is still reserved server-side, so an email in that list gets rejected on update.
The server rejects the request with HTTP 403 if you try to assign an app you don't own or aren't assigned to yourself. That check runs assignable_app_ids(actor) and compares each requested id.
Leave the app list empty when you want to stage a reseller before granting scope. Uncheck individual apps to grant a partial slice.
Per-reseller permissions
Resellers have a boolean permission matrix stored in sellers.permissions. Every key defaults to false in PermissionSet, so omitting a key resets it: always send the full object when you PATCH /api/team/{id}/permissions.
The keys, in the order they appear in the UI:
| Key | UI label | Effect |
|---|---|---|
view_keys | View Keys | Access the Manage Keys panel |
view_hwid | View HWID | See HWIDs on user rows; also gates activation screenshots |
view_ip | View IP Addresses | See IP columns, geo, city, region, country, and any IP-shaped substring in log detail strings |
view_auth_logs | View Auth Logs | Access the Auth Logs page |
view_blocked_ips | View Blocked IPs | Access the Blocked IPs page |
generate_keys | Generate Keys | Access the Generate Keys page |
ban_unban_keys | Ban / Unban Keys | Toggle key ban state |
freeze_unfreeze | Freeze / Unfreeze | Freeze keys |
reset_hwid | Reset HWID | Reset the HWID lock on a key |
delete_keys | Delete Keys & Users | Hard-delete license keys and end-user rows |
view_stats | View Stats | See the dashboard stats tiles |
Owner, Admin, and Developer accounts short-circuit reseller_has_permission() to true, so this checklist only matters for reseller rows.
strip_reseller_hidden() in access.py scrubs hwid, known_hwids, screenshot_path, activation_screenshot_path, last_screenshot_path, ip, known_countries, geo blocks, and any IP-shaped substrings in detail fields before handing them back to a reseller. Denying view_ip really does strip IPs everywhere they can appear, not just on the Users tab.
Update the matrix directly:
{
"permissions": {
"view_keys": true,
"generate_keys": true,
"view_hwid": false,
"view_ip": false,
"view_auth_logs": true,
"view_blocked_ips": false,
"ban_unban_keys": true,
"freeze_unfreeze": true,
"reset_hwid": false,
"delete_keys": false,
"view_stats": true
}
}
Settings dialog
The Gear icon opens the Settings dialog for the row. It gathers everything that decides what the account can do into one form and only fires the necessary requests on save.
Role change is visible only to owners and developers, and the target's role has to be admin or reseller. Owner rows and developer rows have the Gear disabled outright.
Application Access is a checkbox list against every app you can assign. Saving with a changed selection calls PATCH /api/team/{id}/apps with the full desired list, which triggers two side effects: revoke_all_user_sessions(member_id) runs immediately, and if the target is an admin the removal cascades to every reseller under them.
Credits shows only for resellers. Enter a positive or negative delta and save: the request posts { amount } to POST /api/team/{id}/credits, the server clamps the resulting balance to >= 0, and both credits and key_quota are written together. Trying to top up an admin returns Credits only apply to resellers.
Status buttons flip enabled. The change lands in the same PATCH /api/team/{id} request as any role or permissions change, and any session-invalidating field (role, enabled, apps, email) kicks every live session for the target.
Subscription (admin targets)
When the target is an admin and you are the owner, the Settings dialog also shows a Subscription panel. It reads subscription_expires_at and colours the status line: green unlimited, cyan for a distant date, orange when seven days or fewer are left, red when expired.
Four preset buttons extend the current expiry by 7, 30, 90, or 365 days. A date picker plus set writes an exact expiry (end of day UTC), and clear drops the cap entirely. All three variants hit PATCH /api/team/{id}/subscription.
The endpoint accepts exactly one of clear, extend_days, or expires_at per request. Extending an expired subscription resets the base to now_utc() first, so +30 from an expired date gives you thirty days from today, not from the old expiry.
Security dialog
The Lock icon opens the Security dialog, where you can change the target's email, their password, or both. Leave either field blank to skip it.
Setting an email calls PATCH /api/team/{id} with { email }. The server lower-cases the value, checks for a duplicate on another row, and rejects anything in DEVELOPER_EMAILS. Setting a password posts to POST /api/team/{id}/password, which requires eight characters and runs the same enforce_password_policy checks as registration.
Any change to email or password revokes every session the target has open. They get kicked the moment the request returns; there is no way to keep another tab signed in.
Caveats worth knowing:
- You cannot edit your own account from this dialog.
get_manageable_account()refuses withCannot manage your own account here. Change your own password from Settings instead. - You cannot reset an owner's password from anywhere.
_protect_owner()blocks the call. - Parent admins cannot reset another admin's password, only their own resellers. Only the owner can reset any admin's password.
- The password never appears in an audit log field, and there is no recovery. If the user forgets it, run the endpoint again.
Webhook overrides
The Users page does not surface a webhook input anymore. Both endpoints still exist on the backend, and both run the URL through validate_public_https_url(), so non-https, non-public, or malformed URLs are rejected:
PATCH /api/account/webhookwrites your own global alert webhook. Resellers hit HTTP 403 withResellers don't own apps, so a global webhook has no effect, and the error tells them to set per-app webhooks instead.PATCH /api/team/{id}/webhookwrites a per-member default that fires when a key belonging to that member triggers an alert, unless the app overrides it.
Until a webhook field returns to the UI, call the endpoints directly or set the per-app webhook from the application's own Settings tab.
Delete and freeze
Freeze flips enabled to false via the Status buttons inside Settings and revokes every session for the member. The account still exists, keys they generated still work, but the panel login is dead.
Delete is destructive but self-healing for hierarchy. team.py runs three statements in order:
await db.sellers.update_many({"parent_id": member_id}, {"$set": {"parent_id": account["id"]}})
await db.sellers.delete_one({"id": member_id})
await revoke_all_user_sessions(member_id)
Every child is re-parented to the actor, then the row is removed and sessions are killed. The response includes adopted_children so you can confirm how many resellers just landed under you.
License keys and end users the deleted account created stay put. They keep their created_by reference to the now-deleted id.