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:
co-authored by
Claude Opus 4.8
parent
8486079c56
commit
7702110686
@@ -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)):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user