mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
added Role support (Admin, Operator) and Role Managment in Settings
This commit is contained in:
@@ -1 +1 @@
|
||||
310
|
||||
312
|
||||
|
||||
+89
-6
@@ -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; }
|
||||
}
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user