added Role support (Admin, Operator) and Role Managment in Settings

This commit is contained in:
Oliver Kieselbach
2026-03-07 20:45:18 +01:00
parent 6a4f57a933
commit 1d892c9917
14 changed files with 558 additions and 133 deletions
@@ -1 +1 @@
310
312
@@ -115,10 +115,11 @@ public class TenantAdminManagementFunction
return badRequestResponse;
}
// Add the admin
var newAdmin = await _tenantAdminsService.AddTenantAdminAsync(tenantId, body.Upn, upn!);
// Determine role (default to Admin for backward compat)
var role = !string.IsNullOrWhiteSpace(body.Role) ? body.Role : AutopilotMonitor.Shared.Constants.TenantRoles.Admin;
var newAdmin = await _tenantAdminsService.AddTenantMemberAsync(tenantId, body.Upn, upn!, role, body.CanManageBootstrapTokens);
_logger.LogInformation($"Tenant Admin added: {body.Upn} to tenant {tenantId} by {upn}");
_logger.LogInformation($"Tenant member added: {body.Upn} with role {role} to tenant {tenantId} by {upn}");
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { admin = newAdmin });
@@ -164,11 +165,12 @@ public class TenantAdminManagementFunction
// Check if trying to remove self
if (adminUpn.Equals(upn, StringComparison.OrdinalIgnoreCase))
{
// Check if this is the last admin (only for non-Galactic-Admins)
// Check if this is the last Admin-role member (only for non-Galactic-Admins)
if (!isGalacticAdmin)
{
var admins = await _tenantAdminsService.GetTenantAdminsAsync(tenantId);
if (admins.Count == 1)
var members = await _tenantAdminsService.GetTenantAdminsAsync(tenantId);
var adminCount = members.Count(m => m.IsEnabled && (m.Role == null || m.Role == AutopilotMonitor.Shared.Constants.TenantRoles.Admin));
if (adminCount <= 1)
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Cannot remove yourself as the last admin. Please add another admin first." });
@@ -276,9 +278,90 @@ public class TenantAdminManagementFunction
await response.WriteAsJsonAsync(new { message = "Tenant Admin enabled successfully" });
return response;
}
/// <summary>
/// PATCH /api/tenants/{tenantId}/admins/{adminUpn}/permissions
/// Updates role and permissions for a tenant member
/// Accessible by: Galactic Admins OR Tenant Admins of the same tenant
/// </summary>
[Function("UpdateMemberPermissions")]
[Authorize]
public async Task<HttpResponseData> UpdateMemberPermissions(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "tenants/{tenantId}/admins/{adminUpn}/permissions")] HttpRequestData req,
string tenantId,
string adminUpn,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Galactic Admin OR Tenant Admin of the same tenant
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGalacticAdmin && !isTenantAdmin)
{
_logger.LogWarning("User {Upn} attempted to update member permissions for tenant {TenantId} without authorization", upn, tenantId);
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { error = "Access denied. You must be a Galactic Admin or a Tenant Admin for this tenant." });
return forbiddenResponse;
}
var body = await req.ReadFromJsonAsync<UpdateMemberPermissionsRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Role))
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Role is required" });
return badRequestResponse;
}
// Prevent demoting yourself if you're the last Admin
if (adminUpn.Equals(upn, StringComparison.OrdinalIgnoreCase) && body.Role != AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
{
if (!isGalacticAdmin)
{
var members = await _tenantAdminsService.GetTenantAdminsAsync(tenantId);
var adminCount = members.Count(m => m.IsEnabled && (m.Role == null || m.Role == AutopilotMonitor.Shared.Constants.TenantRoles.Admin));
if (adminCount <= 1)
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Cannot demote yourself as the last admin. Please add another admin first." });
return badRequestResponse;
}
}
}
var updated = await _tenantAdminsService.UpdateMemberPermissionsAsync(tenantId, adminUpn, body.Role, body.CanManageBootstrapTokens);
if (!updated)
{
var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound);
await notFoundResponse.WriteAsJsonAsync(new { error = "Member not found" });
return notFoundResponse;
}
_logger.LogInformation("Member permissions updated: {AdminUpn} -> role={Role} in tenant {TenantId} by {Upn}", adminUpn, body.Role, tenantId, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Member permissions updated successfully" });
return response;
}
}
public class AddTenantAdminRequest
{
public string Upn { get; set; } = string.Empty;
public string? Role { get; set; }
public bool CanManageBootstrapTokens { get; set; }
}
public class UpdateMemberPermissionsRequest
{
public string Role { get; set; } = string.Empty;
public bool CanManageBootstrapTokens { get; set; }
}
@@ -82,13 +82,13 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
}
// Admin check
// Admin or Operator with bootstrap permission check
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
var isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, userIdentifier);
if (!isGalacticAdmin && !isTenantAdmin)
var canManageBootstrap = await _tenantAdminsService.CanManageBootstrapAsync(tenantId, userIdentifier);
if (!isGalacticAdmin && !canManageBootstrap)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Tenant admin access required" });
await forbidden.WriteAsJsonAsync(new { error = "Bootstrap token management access required" });
return forbidden;
}
@@ -67,13 +67,13 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
}
// Admin check
// Admin or Operator with bootstrap permission check
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
var isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, userIdentifier);
if (!isGalacticAdmin && !isTenantAdmin)
var canManageBootstrap = await _tenantAdminsService.CanManageBootstrapAsync(tenantId, userIdentifier);
if (!isGalacticAdmin && !canManageBootstrap)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Tenant admin access required" });
await forbidden.WriteAsJsonAsync(new { error = "Bootstrap token management access required" });
return forbidden;
}
@@ -66,13 +66,13 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
}
// Admin check
// Admin or Operator with bootstrap permission check
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
var isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, userIdentifier);
if (!isGalacticAdmin && !isTenantAdmin)
var canManageBootstrap = await _tenantAdminsService.CanManageBootstrapAsync(tenantId, userIdentifier);
if (!isGalacticAdmin && !canManageBootstrap)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Tenant admin access required" });
await forbidden.WriteAsJsonAsync(new { error = "Bootstrap token management access required" });
return forbidden;
}
@@ -157,6 +157,11 @@ public class AuthFunction
}
}
// Get role info for the user (Admin, Operator, Viewer, or null if not a member)
var memberRole = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn);
string? role = memberRole?.Role;
bool canManageBootstrapTokens = memberRole?.CanManageBootstrapTokens ?? false;
// Record user login activity for metrics tracking
_ = _storageService.RecordUserLoginAsync(tenantId, upn, displayName, objectId)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
@@ -168,7 +173,9 @@ public class AuthFunction
displayName,
objectId,
isGalacticAdmin,
isTenantAdmin
isTenantAdmin,
role,
canManageBootstrapTokens
};
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -61,11 +61,12 @@ public class TenantAdminsService
// Query Table Storage
_logger.LogInformation($"Querying Table Storage for Tenant Admin: {upn} @ {tenantId}");
var admin = await GetTenantAdminAsync(tenantId, upn);
var result = admin != null && admin.IsEnabled;
// Only true for Admin role (null Role = Admin for backward compat)
var result = admin != null && admin.IsEnabled
&& (admin.Role == null || admin.Role == Constants.TenantRoles.Admin);
_logger.LogInformation($"Tenant Admin check result: {upn} @ {tenantId} -> {result} (Entity found: {admin != null}, IsEnabled: {admin?.IsEnabled})");
// Cache the result
_cache.Set(cacheKey, result, _cacheDuration);
return result;
@@ -132,8 +133,7 @@ public class TenantAdminsService
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpsertEntityAsync(entity);
// Invalidate cache
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
InvalidateMemberCache(tenantId, upn);
_logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}");
@@ -151,8 +151,7 @@ public class TenantAdminsService
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.DeleteEntityAsync(tenantId, upn);
// Invalidate cache
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
InvalidateMemberCache(tenantId, upn);
_logger.LogInformation($"Removed Tenant Admin: {upn} from tenant {tenantId}");
}
@@ -173,8 +172,7 @@ public class TenantAdminsService
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpdateEntityAsync(admin, Azure.ETag.All);
// Invalidate cache
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
InvalidateMemberCache(tenantId, upn);
_logger.LogInformation($"Disabled Tenant Admin: {upn} for tenant {tenantId}");
}
@@ -196,8 +194,7 @@ public class TenantAdminsService
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpdateEntityAsync(admin, Azure.ETag.All);
// Invalidate cache
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
InvalidateMemberCache(tenantId, upn);
_logger.LogInformation($"Enabled Tenant Admin: {upn} for tenant {tenantId}");
}
@@ -235,10 +232,132 @@ public class TenantAdminsService
// In production, consider using a distributed cache with better cache invalidation
// For now, cache entries will expire after _cacheDuration
}
// -----------------------------------------------------------------------
// Role-aware methods (Admin / Operator / Viewer)
// -----------------------------------------------------------------------
/// <summary>
/// Gets the role info for a tenant member. Returns null if user is not a member.
/// </summary>
public async Task<MemberRoleInfo?> GetMemberRoleAsync(string tenantId, string? upn)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(upn))
return null;
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
var cacheKey = $"tenant-member:{tenantId}:{upn}";
if (_cache.TryGetValue<MemberRoleInfo?>(cacheKey, out var cached))
return cached;
var entity = await GetTenantAdminAsync(tenantId, upn);
if (entity == null || !entity.IsEnabled)
{
_cache.Set(cacheKey, (MemberRoleInfo?)null, _cacheDuration);
return null;
}
var role = new MemberRoleInfo
{
Role = entity.Role ?? Constants.TenantRoles.Admin,
CanManageBootstrapTokens = entity.CanManageBootstrapTokens
};
_cache.Set(cacheKey, (MemberRoleInfo?)role, _cacheDuration);
return role;
}
/// <summary>
/// Checks if a user is a tenant member (Admin or Operator) and enabled.
/// </summary>
public async Task<bool> IsTenantMemberAsync(string tenantId, string? upn)
{
var role = await GetMemberRoleAsync(tenantId, upn);
return role != null && role.Role != Constants.TenantRoles.Viewer;
}
/// <summary>
/// Checks if a user can manage bootstrap tokens (Admin, or Operator with CanManageBootstrapTokens).
/// </summary>
public async Task<bool> CanManageBootstrapAsync(string tenantId, string? upn)
{
var role = await GetMemberRoleAsync(tenantId, upn);
if (role == null) return false;
if (role.Role == Constants.TenantRoles.Admin) return true;
return role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens;
}
/// <summary>
/// Adds a tenant member with a specific role.
/// </summary>
public async Task<TenantAdminEntity> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
{
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
var entity = new TenantAdminEntity
{
PartitionKey = tenantId,
RowKey = upn,
TenantId = tenantId,
Upn = upn,
IsEnabled = true,
AddedDate = DateTime.UtcNow,
AddedBy = addedBy,
Role = role,
CanManageBootstrapTokens = canManageBootstrapTokens
};
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpsertEntityAsync(entity);
InvalidateMemberCache(tenantId, upn);
_logger.LogInformation("Added tenant member: {Upn} with role {Role} to tenant {TenantId} by {AddedBy}", upn, role, tenantId, addedBy);
return entity;
}
/// <summary>
/// Updates role and permissions for an existing tenant member.
/// </summary>
public async Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens)
{
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
var entity = await GetTenantAdminAsync(tenantId, upn);
if (entity == null) return false;
entity.Role = role;
entity.CanManageBootstrapTokens = canManageBootstrapTokens;
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpdateEntityAsync(entity, Azure.ETag.All);
InvalidateMemberCache(tenantId, upn);
_logger.LogInformation("Updated member permissions: {Upn} -> role={Role}, canManageBootstrap={CanManageBootstrap} in tenant {TenantId}", upn, role, canManageBootstrapTokens, tenantId);
return true;
}
/// <summary>
/// Invalidates both admin and member cache entries for a user.
/// </summary>
private void InvalidateMemberCache(string tenantId, string upn)
{
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
_cache.Remove($"tenant-member:{tenantId}:{upn}");
}
}
/// <summary>
/// Entity representing a Tenant Admin in Table Storage
/// Entity representing a tenant member (Admin, Operator, or Viewer) in Table Storage.
/// Stored in the TenantAdmins table for backward compatibility.
/// </summary>
public class TenantAdminEntity : ITableEntity
{
@@ -258,12 +377,12 @@ public class TenantAdminEntity : ITableEntity
public string Upn { get; set; } = string.Empty;
/// <summary>
/// Whether this admin is currently enabled
/// Whether this member is currently enabled
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// When this admin was added
/// When this member was added
/// </summary>
public DateTime AddedDate { get; set; }
@@ -271,4 +390,25 @@ public class TenantAdminEntity : ITableEntity
/// UPN of the admin who added this user
/// </summary>
public string AddedBy { get; set; } = string.Empty;
/// <summary>
/// Role: "Admin", "Operator", "Viewer".
/// Null is treated as "Admin" for backward compatibility with existing entities.
/// </summary>
public string? Role { get; set; }
/// <summary>
/// Whether this Operator can manage bootstrap tokens.
/// Only relevant for the Operator role.
/// </summary>
public bool CanManageBootstrapTokens { get; set; }
}
/// <summary>
/// Role information for a tenant member.
/// </summary>
public class MemberRoleInfo
{
public string Role { get; set; } = Constants.TenantRoles.Admin;
public bool CanManageBootstrapTokens { get; set; }
}
@@ -138,6 +138,21 @@ namespace AutopilotMonitor.Shared
public const string Network = "Network";
}
// -----------------------------------------------------------------------
// Tenant Roles
// -----------------------------------------------------------------------
/// <summary>
/// Roles for tenant members stored in the TenantAdmins table.
/// Null/missing Role on existing entities is treated as Admin for backward compatibility.
/// </summary>
public static class TenantRoles
{
public const string Admin = "Admin";
public const string Operator = "Operator";
public const string Viewer = "Viewer";
}
// -----------------------------------------------------------------------
// Azure Table Storage table names
// All table names are defined here centrally and initialized at application startup
+2 -2
View File
@@ -164,12 +164,12 @@ export default function LandingPage() {
const router = useRouter();
const [platformStats, setPlatformStats] = useState(DEFAULT_PLATFORM_STATS);
// Redirect after login: preview-blocked → /preview, admins → /dashboard, users → /progress
// Redirect after login: preview-blocked → /preview, admins/operators → /dashboard, users → /progress
useEffect(() => {
if (isAuthenticated && !isLoading && user) {
if (isPreviewBlocked) {
router.push("/preview");
} else if (user.isTenantAdmin || user.isGalacticAdmin) {
} else if (user.isTenantAdmin || user.isGalacticAdmin || user.role === 'Operator') {
router.push("/dashboard");
} else {
router.push("/progress");
@@ -7,6 +7,8 @@ interface AdminManagementSectionProps {
loadingAdmins: boolean;
newAdminEmail: string;
setNewAdminEmail: (value: string) => void;
newMemberRole: string;
setNewMemberRole: (value: string) => void;
addingAdmin: boolean;
removingAdmin: string | null;
togglingAdmin: string | null;
@@ -18,6 +20,37 @@ interface AdminManagementSectionProps {
onAddAdmin: () => void;
onRemoveAdmin: (upn: string) => void;
onToggleAdmin: (upn: string, isEnabled: boolean) => void;
onUpdatePermissions: (upn: string, role: string, canManageBootstrapTokens: boolean) => void;
}
function getRoleBadge(role: string | null) {
const effectiveRole = role ?? "Admin";
switch (effectiveRole) {
case "Admin":
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Admin
</span>
);
case "Operator":
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
Operator
</span>
);
case "Viewer":
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Viewer
</span>
);
default:
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
{effectiveRole}
</span>
);
}
}
export default function AdminManagementSection({
@@ -25,6 +58,8 @@ export default function AdminManagementSection({
loadingAdmins,
newAdminEmail,
setNewAdminEmail,
newMemberRole,
setNewMemberRole,
addingAdmin,
removingAdmin,
togglingAdmin,
@@ -36,6 +71,7 @@ export default function AdminManagementSection({
onAddAdmin,
onRemoveAdmin,
onToggleAdmin,
onUpdatePermissions,
}: AdminManagementSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
@@ -45,8 +81,8 @@ export default function AdminManagementSection({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Admin Users Management</h2>
<p className="text-sm text-gray-500 mt-1">Manage who has admin access to this tenant</p>
<h2 className="text-xl font-semibold text-gray-900">Team Management</h2>
<p className="text-sm text-gray-500 mt-1">Manage team members and their roles for this tenant</p>
</div>
</div>
</div>
@@ -57,10 +93,12 @@ export default function AdminManagementSection({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">About Admin Users</p>
<p className="font-medium">About Roles</p>
<p className="mt-1">
Admin users have full access to tenant configuration, all sessions, diagnostics, and settings.
Non-admin users only have access to the simplified device tracking page.
<strong>Admin</strong> — Full access to all tenant configuration, sessions, diagnostics, and settings.
</p>
<p className="mt-1">
<strong>Operator</strong> — Access to dashboard, sessions, and monitoring. Can optionally manage bootstrap tokens if permitted.
</p>
<p className="mt-2">
<strong>Your email:</strong> {user?.upn}
@@ -69,10 +107,10 @@ export default function AdminManagementSection({
</div>
</div>
{/* Current Admins List */}
{/* Current Members List */}
<div>
<label className="block mb-2">
<span className="text-gray-700 font-medium">Current Admin Users</span>
<span className="text-gray-700 font-medium">Current Team Members</span>
{loadingAdmins && (
<span className="ml-2 text-sm text-gray-500">(Loading...)</span>
)}
@@ -114,10 +152,10 @@ export default function AdminManagementSection({
</div>
{admins.length === 0 && !loadingAdmins ? (
<div className="text-sm text-gray-500 italic">No admins found</div>
<div className="text-sm text-gray-500 italic">No members found</div>
) : (
<>
{/* Filtered and Paginated Admin List */}
{/* Filtered and Paginated Member List */}
{(() => {
const filteredAdmins = admins.filter(admin =>
admin.upn.toLowerCase().includes(adminSearchQuery.toLowerCase())
@@ -126,7 +164,7 @@ export default function AdminManagementSection({
if (filteredAdmins.length === 0) {
return (
<div className="text-sm text-gray-500 italic p-4 text-center bg-gray-50 rounded-lg">
No admins match your search
No members match your search
</div>
);
}
@@ -140,60 +178,94 @@ export default function AdminManagementSection({
return (
<>
<div className="space-y-2">
{paginatedAdmins.map((admin) => (
<div
key={admin.upn}
className={`flex items-center justify-between p-3 border rounded-lg ${
admin.isEnabled
? "bg-gray-50 border-gray-200"
: "bg-gray-100 border-gray-300"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate">{admin.upn}</div>
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
{paginatedAdmins.map((admin) => {
const effectiveRole = admin.role ?? "Admin";
const isCurrentUser = admin.upn.toLowerCase() === user?.upn?.toLowerCase();
return (
<div
key={admin.upn}
className={`p-3 border rounded-lg ${
admin.isEnabled
? "bg-gray-50 border-gray-200"
: "bg-gray-100 border-gray-300"
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate">{admin.upn}</div>
{getRoleBadge(admin.role)}
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex items-center space-x-2 ml-4">
{isCurrentUser ? (
<span className="text-sm text-blue-600 font-medium">(You)</span>
) : (
<>
{/* Role change dropdown */}
<select
value={effectiveRole}
onChange={(e) => onUpdatePermissions(admin.upn, e.target.value, admin.canManageBootstrapTokens)}
disabled={togglingAdmin === admin.upn}
className="px-2 py-1 text-sm border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
</select>
<button
onClick={() => onToggleAdmin(admin.upn, admin.isEnabled)}
disabled={togglingAdmin === admin.upn}
className={`px-3 py-1 text-sm text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? "bg-yellow-600 hover:bg-yellow-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled
? "Disable"
: "Enable"}
</button>
<button
onClick={() => onRemoveAdmin(admin.upn)}
disabled={removingAdmin === admin.upn}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{removingAdmin === admin.upn ? "Removing..." : "Remove"}
</button>
</>
)}
</div>
</div>
<div className="text-xs text-gray-500 mt-1">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex items-center space-x-2 ml-4">
{admin.upn.toLowerCase() === user?.upn?.toLowerCase() ? (
<span className="text-sm text-blue-600 font-medium">(You)</span>
) : (
<>
<button
onClick={() => onToggleAdmin(admin.upn, admin.isEnabled)}
disabled={togglingAdmin === admin.upn}
className={`px-3 py-1 text-sm text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? "bg-yellow-600 hover:bg-yellow-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled
? "Disable"
: "Enable"}
</button>
<button
onClick={() => onRemoveAdmin(admin.upn)}
disabled={removingAdmin === admin.upn}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{removingAdmin === admin.upn ? "Removing..." : "Remove"}
</button>
</>
{/* Bootstrap token permission toggle for Operators */}
{effectiveRole === "Operator" && !isCurrentUser && (
<div className="mt-2 pt-2 border-t border-gray-200">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={admin.canManageBootstrapTokens}
onChange={(e) => onUpdatePermissions(admin.upn, "Operator", e.target.checked)}
disabled={togglingAdmin === admin.upn}
className="h-4 w-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700">Can manage bootstrap tokens</span>
</label>
</div>
)}
</div>
</div>
))}
);
})}
</div>
{/* Pagination Controls */}
@@ -207,7 +279,7 @@ export default function AdminManagementSection({
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''})
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} member{filteredAdmins.length !== 1 ? 's' : ''})
</span>
<button
onClick={() => setCurrentAdminPage(prev => Math.min(totalAdminPages - 1, prev + 1))}
@@ -225,13 +297,12 @@ export default function AdminManagementSection({
)}
</div>
{/* Add New Admin */}
{/* Add New Member */}
<div>
<label className="block mb-2">
<span className="text-gray-700 font-medium">Add New Admin</span>
<span className="text-gray-700 font-medium">Add New Team Member</span>
<p className="text-sm text-gray-500 mb-2">
Enter the user email (UPN) to grant admin access.
Example: <code className="bg-gray-100 px-1 rounded">newadmin@company.com</code>
Enter the user email (UPN) and select a role to grant access.
</p>
<div className="flex space-x-2">
<input
@@ -240,7 +311,7 @@ export default function AdminManagementSection({
id="add-new-admin-email-input"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder="newadmin@tenant.com"
placeholder="user@tenant.com"
autoComplete="off"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
onKeyDown={(e) => {
@@ -250,6 +321,14 @@ export default function AdminManagementSection({
}
}}
/>
<select
value={newMemberRole}
onChange={(e) => setNewMemberRole(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
</select>
<button
onClick={onAddAdmin}
disabled={addingAdmin || !newAdminEmail.trim()}
@@ -279,7 +358,7 @@ export default function AdminManagementSection({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-yellow-800">
<strong>Important:</strong> Make sure to include your own email in the list to maintain admin access!
<strong>Important:</strong> Make sure to keep at least one Admin in the list to maintain full access!
The first user to log in was automatically made an admin.
</p>
</div>
@@ -77,6 +77,8 @@ export interface TenantAdmin {
isEnabled: boolean;
addedDate: string;
addedBy: string;
role: string | null;
canManageBootstrapTokens: boolean;
}
export default function SettingsPage() {
@@ -92,6 +94,7 @@ export default function SettingsPage() {
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [newAdminEmail, setNewAdminEmail] = useState("");
const [newMemberRole, setNewMemberRole] = useState<string>("Admin");
const [addingAdmin, setAddingAdmin] = useState(false);
const [removingAdmin, setRemovingAdmin] = useState<string | null>(null);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
@@ -644,16 +647,17 @@ export default function SettingsPage() {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ upn: newAdminEmail.trim() }),
body: JSON.stringify({ upn: newAdminEmail.trim(), role: newMemberRole }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to add admin: ${response.statusText}`);
throw new Error(errorData.error || `Failed to add member: ${response.statusText}`);
}
setSuccessMessage(`Admin ${newAdminEmail} added successfully!`);
setSuccessMessage(`${newMemberRole} ${newAdminEmail} added successfully!`);
setNewAdminEmail("");
setNewMemberRole("Admin");
// Refresh admin list
await fetchAdmins();
@@ -760,6 +764,43 @@ export default function SettingsPage() {
}
};
const handleUpdatePermissions = async (adminUpn: string, role: string, canManageBootstrapTokens: boolean) => {
if (!tenantId) return;
try {
setTogglingAdmin(adminUpn);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`,
getAccessToken,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role, canManageBootstrapTokens }),
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to update permissions: ${response.statusText}`);
}
setSuccessMessage(`Permissions for ${adminUpn} updated successfully!`);
await fetchAdmins();
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
} else {
console.error("Error updating permissions:", err);
setError(err instanceof Error ? err.message : "Failed to update permissions");
}
} finally {
setTogglingAdmin(null);
}
};
// Bootstrap sessions
const fetchBootstrapSessions = async () => {
@@ -858,10 +899,19 @@ export default function SettingsPage() {
}
};
// Redirect regular users (non-TenantAdmin) to progress portal
// Access gate: Admin → full settings, Operator with bootstrap → bootstrap only, others → redirect
if (user && !user.isTenantAdmin && !user.isGalacticAdmin) {
router.replace('/progress');
return null;
if (user.role === 'Operator' && user.canManageBootstrapTokens) {
// Operator with bootstrap permission — allowed, will see only bootstrap section
} else if (user.role === 'Operator') {
// Operator without bootstrap permission — no settings access
router.replace('/dashboard');
return null;
} else {
// Regular user — redirect to progress portal
router.replace('/progress');
return null;
}
}
return (
@@ -883,7 +933,7 @@ export default function SettingsPage() {
<p className="text-sm text-gray-600 mt-1">Tenant: {tenantId}</p>
</div>
</div>
{!loading && (
{!loading && user?.isTenantAdmin && (
<div className="flex items-center space-x-3">
<button
onClick={handleSave}
@@ -935,15 +985,18 @@ export default function SettingsPage() {
)}
<AutopilotValidationSection
validateAutopilotDevice={validateAutopilotDevice}
setValidateAutopilotDevice={setValidateAutopilotDevice}
validateCorporateIdentifier={validateCorporateIdentifier}
setValidateCorporateIdentifier={setValidateCorporateIdentifier}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={saving}
onBeginConsent={beginDeviceValidationConsentFlow}
/>
{/* Admin-only sections — hidden for Operators */}
{user?.isTenantAdmin && (
<AutopilotValidationSection
validateAutopilotDevice={validateAutopilotDevice}
setValidateAutopilotDevice={setValidateAutopilotDevice}
validateCorporateIdentifier={validateCorporateIdentifier}
setValidateCorporateIdentifier={setValidateCorporateIdentifier}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={saving}
onBeginConsent={beginDeviceValidationConsentFlow}
/>
)}
{user?.isTenantAdmin && (
<AdminManagementSection
@@ -951,6 +1004,8 @@ export default function SettingsPage() {
loadingAdmins={loadingAdmins}
newAdminEmail={newAdminEmail}
setNewAdminEmail={setNewAdminEmail}
newMemberRole={newMemberRole}
setNewMemberRole={setNewMemberRole}
addingAdmin={addingAdmin}
removingAdmin={removingAdmin}
togglingAdmin={togglingAdmin}
@@ -962,16 +1017,20 @@ export default function SettingsPage() {
onAddAdmin={handleAddAdmin}
onRemoveAdmin={handleRemoveAdmin}
onToggleAdmin={handleToggleTenantAdmin}
onUpdatePermissions={handleUpdatePermissions}
/>
)}
<HardwareWhitelistSection
manufacturerWhitelist={manufacturerWhitelist}
setManufacturerWhitelist={setManufacturerWhitelist}
modelWhitelist={modelWhitelist}
setModelWhitelist={setModelWhitelist}
/>
{user?.isTenantAdmin && (
<HardwareWhitelistSection
manufacturerWhitelist={manufacturerWhitelist}
setManufacturerWhitelist={setManufacturerWhitelist}
modelWhitelist={modelWhitelist}
setModelWhitelist={setModelWhitelist}
/>
)}
{user?.isTenantAdmin && (
<AgentSettingsSection
enablePerformanceCollector={enablePerformanceCollector}
setEnablePerformanceCollector={setEnablePerformanceCollector}
@@ -1002,7 +1061,9 @@ export default function SettingsPage() {
enrollmentSummaryBrandingImageUrl={enrollmentSummaryBrandingImageUrl}
setEnrollmentSummaryBrandingImageUrl={setEnrollmentSummaryBrandingImageUrl}
/>
)}
{user?.isTenantAdmin && (
<AgentAnalyzersSection
enableLocalAdminAnalyzer={enableLocalAdminAnalyzer}
setEnableLocalAdminAnalyzer={setEnableLocalAdminAnalyzer}
@@ -1011,7 +1072,9 @@ export default function SettingsPage() {
newAllowedAccount={newAllowedAccount}
setNewAllowedAccount={setNewAllowedAccount}
/>
)}
{user?.isTenantAdmin && (
<TeamsNotificationsSection
teamsWebhookUrl={teamsWebhookUrl}
setTeamsWebhookUrl={setTeamsWebhookUrl}
@@ -1020,7 +1083,9 @@ export default function SettingsPage() {
teamsNotifyOnFailure={teamsNotifyOnFailure}
setTeamsNotifyOnFailure={setTeamsNotifyOnFailure}
/>
)}
{user?.isTenantAdmin && (
<DiagnosticsSection
diagnosticsBlobSasUrl={diagnosticsBlobSasUrl}
setDiagnosticsBlobSasUrl={setDiagnosticsBlobSasUrl}
@@ -1035,8 +1100,10 @@ export default function SettingsPage() {
newDiagDesc={newDiagDesc}
setNewDiagDesc={setNewDiagDesc}
/>
)}
{config?.bootstrapTokenEnabled && (
{/* Bootstrap section: visible to Admins always, Operators with bootstrap permission */}
{config?.bootstrapTokenEnabled && (user?.isTenantAdmin || user?.canManageBootstrapTokens) && (
<BootstrapSessionsSection
sessions={bootstrapSessions}
loading={bootstrapLoading}
@@ -1046,6 +1113,7 @@ export default function SettingsPage() {
/>
)}
{user?.isTenantAdmin && (
<DataManagementSection
dataRetentionDays={dataRetentionDays}
setDataRetentionDays={setDataRetentionDays}
@@ -1053,6 +1121,7 @@ export default function SettingsPage() {
setSessionTimeoutHours={setSessionTimeoutHours}
isGalacticAdmin={user?.isGalacticAdmin}
/>
)}
{user?.isTenantAdmin && (
<OffboardingSection
@@ -1067,8 +1136,8 @@ export default function SettingsPage() {
/>
)}
{/* Configuration Info */}
{config && (
{/* Configuration Info — Admin only */}
{config && user?.isTenantAdmin && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -156,9 +156,11 @@ export default function Navbar() {
};
const isTenantAdmin = user?.isTenantAdmin ?? false;
const isOperator = user?.role === 'Operator';
const isAdminOrOperator = isTenantAdmin || isOperator;
// Regular users (non-TenantAdmin): show minimal navbar with only Progress Portal
if (!isTenantAdmin && !user?.isGalacticAdmin) {
// Regular users (non-Admin, non-Operator): show minimal navbar with only Progress Portal
if (!isAdminOrOperator && !user?.isGalacticAdmin) {
return (
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -357,7 +359,9 @@ export default function Navbar() {
{showSettings && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[32rem] overflow-y-auto">
<div className="p-3">
{/* ADMINISTRATION */}
{/* ADMINISTRATION — only for Admins */}
{isTenantAdmin && (
<>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-2">Administration</p>
{/* Admin Mode Toggle */}
@@ -373,6 +377,8 @@ export default function Navbar() {
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${adminMode ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
</>
)}
{/* Galactic Admin Toggle */}
{user?.isGalacticAdmin && (
@@ -416,7 +422,9 @@ export default function Navbar() {
<span className="text-sm text-gray-700">Geographic Performance</span>
</Link>
{/* CONFIGURATION */}
{/* CONFIGURATION — Admin-only items + Settings for Operators with bootstrap permission */}
{(isTenantAdmin || (isOperator && user?.canManageBootstrapTokens)) && (
<>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mt-3 mb-1.5">Configuration</p>
<Link href="/settings" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowSettings(false)}>
@@ -425,7 +433,11 @@ export default function Navbar() {
</svg>
<span className="text-sm text-gray-700">Configuration</span>
</Link>
</>
)}
{isTenantAdmin && (
<>
<Link href="/gather-rules" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowSettings(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@@ -448,6 +460,8 @@ export default function Navbar() {
<span className="text-sm text-gray-700">IME Log Patterns</span>
</Link>
)}
</>
)}
{/* OPERATIONS */}
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mt-3 mb-1.5">Operations</p>
@@ -600,6 +614,16 @@ export default function Navbar() {
Galactic Admin
</span>
)}
{isOperator && (
<span className="inline-block mt-1.5 px-1.5 py-0.5 text-[10px] font-semibold text-blue-800 bg-blue-100 rounded-full">
Operator
</span>
)}
{isTenantAdmin && !user?.isGalacticAdmin && (
<span className="inline-block mt-1.5 px-1.5 py-0.5 text-[10px] font-semibold text-green-800 bg-green-100 rounded-full">
Admin
</span>
)}
</div>
</div>
<div className="py-1">
@@ -24,6 +24,8 @@ interface UserInfo {
objectId: string;
isGalacticAdmin: boolean;
isTenantAdmin: boolean;
role: 'Admin' | 'Operator' | 'Viewer' | null;
canManageBootstrapTokens: boolean;
}
interface AuthContextType {
@@ -111,6 +113,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
objectId: account.homeAccountId || '',
isGalacticAdmin: false,
isTenantAdmin: false,
role: null,
canManageBootstrapTokens: false,
};
}
}
@@ -126,6 +130,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
objectId: data.objectId || account.homeAccountId || '',
isGalacticAdmin: data.isGalacticAdmin || false,
isTenantAdmin: data.isTenantAdmin || false,
role: data.role || null,
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
};
} catch (error) {
console.error('[Auth] Failed to fetch user info:', error);
@@ -138,6 +144,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
objectId: account.homeAccountId || '',
isGalacticAdmin: false,
isTenantAdmin: false,
role: null,
canManageBootstrapTokens: false,
};
}
}, [instance]);
File diff suppressed because one or more lines are too long