feat(admin): quick-assign a role by email from the Roles tab

Each role's detail panel gains an "Add someone to <role>" box: type an
email and they're placed in that role. Existing users are switched to the
role; new company SSO (@saplind.com) emails are auto-created (no password,
Google sign-in). Non-SSO new emails are directed to the Users tab. Backend
POST /admin/roles/{role}/members upserts with the last-active-admin guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ajay Sable
2026-07-01 17:09:38 +05:30
co-authored by Claude Opus 4.8
parent 8486079c56
commit 7702110686
3 changed files with 79 additions and 1 deletions
+41
View File
@@ -136,6 +136,47 @@ def create_user(req: UserCreate, db: Session = Depends(get_db)):
return _user_row(db, u)
class RoleMemberAdd(BaseModel):
email: str
name: str | None = None
@router.post("/roles/{role_name}/members", dependencies=[admin_guard])
def add_role_member(role_name: str, req: RoleMemberAdd, db: Session = Depends(get_db)):
"""Quick-assign a role by email from the Roles tab. If the user exists, switch
them to this role; otherwise auto-create them (company SSO emails need no
password). Returns the user row + whether it was newly created."""
role = db.query(Role).filter(Role.name == role_name).first()
if not role:
raise HTTPException(status_code=404, detail=f"Unknown role '{role_name}'")
email = (req.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="email is required")
user = db.query(User).filter(User.email == email).first()
if user:
# Reassigning an existing user — keep the last-active-admin safety net.
if is_admin(user) and role.name != ADMIN_ROLE and _active_admin_count(db) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last active admin")
user.role = role.name
db.commit()
db.refresh(user)
logger.info(f"Role assigned: {email} -> {role.name}")
return {"user": _user_row(db, user), "created": False}
# New user: quick-add only supports company SSO emails (no password to set here).
if not _is_sso_email(email):
raise HTTPException(status_code=400, detail="Add non-SSO users from the Users tab (they need a password)")
user = User(email=email, name=(req.name or email.split("@")[0]).strip(), role=role.name,
department_id=None, is_active=True,
password_hash=hash_password(secrets.token_urlsafe(24)))
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Role member created: {email} ({role.name})")
return {"user": _user_row(db, user), "created": True}
@router.patch("/users/{user_id}", dependencies=[admin_guard])
def update_user(user_id: str, req: UserUpdate, db: Session = Depends(get_db),
me: User = Depends(current_user)):
+3
View File
@@ -135,6 +135,9 @@ export async function updateRole(id: string, body: Partial<{ name: string; descr
export async function deleteRole(id: string): Promise<void> {
await api.delete(`/admin/roles/${id}`);
}
export async function addRoleMember(roleName: string, email: string, name?: string): Promise<{ user: AdminUser; created: boolean }> {
return (await api.post(`/admin/roles/${encodeURIComponent(roleName)}/members`, { email, name })).data;
}
export async function listDepartments(): Promise<Department[]> {
return (await api.get('/admin/departments')).data.departments;
}
+35 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
listAdminUsers, createAdminUser, updateAdminUser, resetAdminUserPassword,
listRoles, createRole, updateRole, deleteRole,
listRoles, createRole, updateRole, deleteRole, addRoleMember,
listDepartments, createDepartment, updateDepartment, deleteDepartment,
getRbacMeta, getAuthConfig,
type AdminUser, type Role, type Department, type RbacMeta, type AccessLevel,
@@ -203,6 +203,11 @@ function RolesTab({ roles, meta, reload, notify }: { roles: Role[]; meta: RbacMe
const [sel, setSel] = useState<Role | null>(roles[0] ?? null);
const [draft, setDraft] = useState<Role | null>(roles[0] ?? null);
const [creating, setCreating] = useState(false);
const [memberEmail, setMemberEmail] = useState('');
const [addingMember, setAddingMember] = useState(false);
const [ssoDomain, setSsoDomain] = useState('');
useEffect(() => { getAuthConfig().then((c) => setSsoDomain((c.google_domain || '').toLowerCase())).catch(() => {}); }, []);
useEffect(() => {
// keep selection valid after reloads
@@ -210,6 +215,19 @@ function RolesTab({ roles, meta, reload, notify }: { roles: Role[]; meta: RbacMe
setSel(found); setDraft(found ? { ...found, permissions: { ...found.permissions } } : null);
}, [roles]); // eslint-disable-line react-hooks/exhaustive-deps
async function addMember() {
const email = memberEmail.trim().toLowerCase();
if (!email || !draft) return;
setAddingMember(true);
try {
const res = await addRoleMember(draft.name, email);
notify(res.created ? `Added ${email} as ${draft.name}.` : `${email} is now ${draft.name}.`, 'ok');
setMemberEmail('');
reload();
} catch (e) { notify(errMsg(e), 'err'); }
finally { setAddingMember(false); }
}
const isAdmin = draft?.name === 'Admin';
const dirty = !!draft && !!sel && JSON.stringify(draft) !== JSON.stringify(sel);
@@ -295,6 +313,22 @@ function RolesTab({ roles, meta, reload, notify }: { roles: Role[]; meta: RbacMe
</div>
</div>
{/* quick-add a member to this role */}
<div className="mb-4 rounded-lg border border-line bg-paper/40 p-3">
<p className="font-plex text-[10px] uppercase tracking-[0.12em] text-muted mb-2">Add someone to “{draft.name}”</p>
<div className="flex flex-col sm:flex-row gap-2">
<input className={inputCls} placeholder={ssoDomain ? `name@${ssoDomain}` : 'Email'} value={memberEmail}
onChange={(e) => setMemberEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMember(); }} />
<button className={btnDark + ' whitespace-nowrap'} disabled={addingMember || !memberEmail.trim()} onClick={addMember}>
{addingMember ? 'Adding…' : 'Add to role'}
</button>
</div>
<p className="text-[11px] text-muted mt-2">
Existing users are switched to this role. New{ssoDomain ? <> <b>@{ssoDomain}</b></> : ''} emails are created and sign in with Google — no password needed.
</p>
</div>
{isAdmin && <p className="text-[12px] mb-3" style={{ color: '#b5651d' }}>The Admin role always has full access to every module — its matrix is fixed.</p>}
{/* permission matrix */}