feat(roles): add dedicated Supervisor (capoturno) role
Until now the out-of-tolerance authorization ("Autorizzazione capoturno")
required is_admin, conflating shift-leader authority with full system
administration. Introduce a combinable Supervisor role so a capoturno can
authorize overrides without admin privileges (roadmap D-0.8 / Phase 2).
Backend:
- users router: add "Supervisor" to the allowed roles (centralized as
VALID_ROLES, deduplicating the create/update validation).
- middleware: add require_supervisor dependency (admins still bypass).
Frontend:
- measure.validate_supervisor: authorize users with the Supervisor role OR
is_admin (was is_admin only).
- admin/users: Supervisor checkbox + orange role badge.
- profile: explicit Supervisor badge styling.
- EN translation for the new role label.
roles is a free JSON column, so no migration is required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from src.backend.api.middleware.api_key import (
|
|||||||
require_maker,
|
require_maker,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
|
require_supervisor,
|
||||||
require_admin_user,
|
require_admin_user,
|
||||||
)
|
)
|
||||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"require_maker",
|
"require_maker",
|
||||||
"require_measurement_tec",
|
"require_measurement_tec",
|
||||||
"require_metrologist",
|
"require_metrologist",
|
||||||
|
"require_supervisor",
|
||||||
"require_admin_user",
|
"require_admin_user",
|
||||||
"AccessLogMiddleware",
|
"AccessLogMiddleware",
|
||||||
"RateLimitMiddleware",
|
"RateLimitMiddleware",
|
||||||
|
|||||||
@@ -68,4 +68,5 @@ def require_admin():
|
|||||||
require_maker = require_role("Maker")
|
require_maker = require_role("Maker")
|
||||||
require_measurement_tec = require_role("MeasurementTec")
|
require_measurement_tec = require_role("MeasurementTec")
|
||||||
require_metrologist = require_role("Metrologist")
|
require_metrologist = require_role("Metrologist")
|
||||||
|
require_supervisor = require_role("Supervisor")
|
||||||
require_admin_user = require_admin()
|
require_admin_user = require_admin()
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from src.backend.services.auth_service import create_user, hash_password, regene
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
# Combinable user roles. Supervisor (capoturno) authorizes out-of-tolerance
|
||||||
|
# overrides during measurement without needing full admin privileges.
|
||||||
|
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist", "Supervisor"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserResponse])
|
@router.get("", response_model=list[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
@@ -40,12 +44,11 @@ async def create_new_user(
|
|||||||
detail=f"Username '{data.username}' already exists",
|
detail=f"Username '{data.username}' already exists",
|
||||||
)
|
)
|
||||||
# Validate roles
|
# Validate roles
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in data.roles:
|
for role in data.roles:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||||
)
|
)
|
||||||
user = await create_user(
|
user = await create_user(
|
||||||
db,
|
db,
|
||||||
@@ -77,12 +80,11 @@ async def update_user(
|
|||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
# Validate roles if provided
|
# Validate roles if provided
|
||||||
if "roles" in update_data:
|
if "roles" in update_data:
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in update_data["roles"]:
|
for role in update_data["roles"]:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||||
)
|
)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|||||||
@@ -347,7 +347,8 @@ def validate_supervisor():
|
|||||||
return jsonify({"error": True, "detail": "Credenziali non valide"}), 401
|
return jsonify({"error": True, "detail": "Credenziali non valide"}), 401
|
||||||
|
|
||||||
user = resp.get("user", {})
|
user = resp.get("user", {})
|
||||||
if not user.get("is_admin"):
|
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
||||||
|
if not (is_supervisor or user.get("is_admin")):
|
||||||
return jsonify({"error": True, "detail": "Utente non autorizzato (richiesto capoturno)"}), 403
|
return jsonify({"error": True, "detail": "Utente non autorizzato (richiesto capoturno)"}), 403
|
||||||
|
|
||||||
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
||||||
|
|||||||
@@ -78,7 +78,8 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
||||||
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
|
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
|
||||||
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist'
|
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist',
|
||||||
|
'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300': role === 'Supervisor'
|
||||||
}"
|
}"
|
||||||
x-text="role"></span>
|
x-text="role"></span>
|
||||||
</template>
|
</template>
|
||||||
@@ -233,6 +234,11 @@
|
|||||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||||
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="form.roles" value="Supervisor"
|
||||||
|
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{{ _('Supervisor (capoturno)') }}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@
|
|||||||
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
|
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
|
||||||
{% elif role == 'Metrologist' %}
|
{% elif role == 'Metrologist' %}
|
||||||
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
|
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
|
||||||
|
{% elif role == 'Supervisor' %}
|
||||||
|
bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800
|
||||||
{% else %}
|
{% else %}
|
||||||
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
|
|||||||
@@ -2185,3 +2185,6 @@ msgstr "E.g. LOT-2026-001 (optional)"
|
|||||||
msgid "Es. SN-000123 (opzionale)"
|
msgid "Es. SN-000123 (opzionale)"
|
||||||
msgstr "E.g. SN-000123 (optional)"
|
msgstr "E.g. SN-000123 (optional)"
|
||||||
|
|
||||||
|
msgid "Supervisor (capoturno)"
|
||||||
|
msgstr "Supervisor (shift leader)"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user