Auth middleware:
- Add getEffectiveRoleIds() that UNIONs user_roles with group_roles via
group memberships, used by requireAdmin / requirePermission /
userHasPermission / getUserRoles.
- requireAdmin and requirePermission now also reject inactive users
with 401 (matching requireAuth), closing a session-after-deactivation
bypass.
Groups routes:
- POST /groups and PATCH /groups/:id now wrap the group row write and
all assignment writes in a single db.transaction via
applyGroupAssignmentsTx(tx, ...), so partial state cannot leak.
- validateAssignmentIds rejects unknown app/role/user ids with 400
before any insert.
- Removed AI-slop: void or, void sql, as-unknown-as casts; conditions
use Drizzle's SQL union type.
Users route:
- /api/users supports q, groupId, status filters (server-side).
Admin UI (teaboy-os/admin.tsx):
- UsersPanel wires q/groupId/status to the backend, shows display name
and preferred language inline per row.
- UserGroupsEditor now edits display names (ar/en), preferred language,
active status, and group membership with a search box.
- GroupsPanel adds a top-level group search box.
- GroupDetailEditor Users tab adds a user search box.
Infra:
- scripts/post-merge.sh runs the seed (idempotent) so default groups
Admins / TeaBoy / Everyone always exist after merges.
Tests (artifacts/api-server/tests/groups-crud.test.mjs, all passing):
- Admin-only access (regular user gets 403).
- Default seed groups exist.
- Create group + member assignment.
- Bad userIds yields 400 with no leaked group row.
- Admin role inherited via group_roles grants admin access.
- Deactivated admin session is rejected with 401.
- Group create rolls back atomically when assignment fails.
- /api/users q + groupId + status filters return correct rows.
Notes / drift:
- "Roles" tab inside GroupDetailEditor and groupIds in CreateUserBody
remain as proposed follow-ups (require OpenAPI spec changes).
- Pre-existing pagination-test flake unrelated to this work.