mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
added tenant offboarding
fix for user login -> only progress portal default theme -> light
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant Offboarding endpoint
|
||||
/// Allows a Tenant Admin to permanently delete all data for their tenant.
|
||||
/// This operation is irreversible.
|
||||
/// </summary>
|
||||
public class TenantOffboardFunction
|
||||
{
|
||||
private readonly ILogger<TenantOffboardFunction> _logger;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly GalacticAdminService _galacticAdminService;
|
||||
private readonly TableServiceClient _tableServiceClient;
|
||||
|
||||
public TenantOffboardFunction(
|
||||
ILogger<TenantOffboardFunction> logger,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
GalacticAdminService galacticAdminService,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_galacticAdminService = galacticAdminService;
|
||||
var connectionString = configuration["AzureTableStorageConnectionString"];
|
||||
_tableServiceClient = new TableServiceClient(connectionString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/tenants/{tenantId}/offboard
|
||||
/// Permanently deletes ALL data for a tenant across all tables.
|
||||
/// Accessible by: Tenant Admins of the same tenant OR Galactic Admins.
|
||||
/// This action is IRREVERSIBLE.
|
||||
/// </summary>
|
||||
[Function("OffboardTenant")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> OffboardTenant(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "tenants/{tenantId}/offboard")] HttpRequestData req,
|
||||
string tenantId,
|
||||
FunctionContext context)
|
||||
{
|
||||
var principal = context.GetUser();
|
||||
if (principal == null)
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
var userTenantId = principal.GetTenantId();
|
||||
var upn = principal.GetUserPrincipalName();
|
||||
|
||||
// Only Tenant Admins of the same tenant OR Galactic Admins may offboard
|
||||
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 offboard tenant {tenantId} without authorization");
|
||||
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbiddenResponse.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Access denied. Only a Tenant Admin of this tenant may perform offboarding."
|
||||
});
|
||||
return forbiddenResponse;
|
||||
}
|
||||
|
||||
_logger.LogWarning($"TENANT OFFBOARD initiated for tenant {tenantId} by {upn}");
|
||||
|
||||
var result = new OffboardResult { TenantId = tenantId, InitiatedBy = upn!, InitiatedAt = DateTime.UtcNow };
|
||||
|
||||
try
|
||||
{
|
||||
// Tables where PartitionKey = tenantId (normalized lowercase)
|
||||
var tenantPartitionTables = new[]
|
||||
{
|
||||
Constants.TableNames.Sessions,
|
||||
Constants.TableNames.AuditLogs,
|
||||
Constants.TableNames.UsageMetrics,
|
||||
Constants.TableNames.UserActivity,
|
||||
Constants.TableNames.RuleResults,
|
||||
Constants.TableNames.GatherRules,
|
||||
Constants.TableNames.AnalyzeRules,
|
||||
Constants.TableNames.AppInstallSummaries,
|
||||
Constants.TableNames.TenantConfiguration,
|
||||
Constants.TableNames.TenantAdmins,
|
||||
};
|
||||
|
||||
var normalizedTenantId = tenantId.ToLowerInvariant();
|
||||
|
||||
foreach (var tableName in tenantPartitionTables)
|
||||
{
|
||||
var deleted = await DeleteAllRowsByPartitionKeyAsync(tableName, normalizedTenantId);
|
||||
result.DeletedCounts[tableName] = deleted;
|
||||
_logger.LogInformation($"Offboard [{tenantId}] {tableName}: deleted {deleted} rows");
|
||||
}
|
||||
|
||||
// Events table uses PartitionKey = "{tenantId}_{sessionId}" – query by prefix
|
||||
var eventsDeleted = await DeleteEventsByTenantPrefixAsync(normalizedTenantId);
|
||||
result.DeletedCounts[Constants.TableNames.Events] = eventsDeleted;
|
||||
_logger.LogInformation($"Offboard [{tenantId}] Events: deleted {eventsDeleted} rows");
|
||||
|
||||
result.Success = true;
|
||||
_logger.LogWarning($"TENANT OFFBOARD completed for tenant {tenantId} by {upn}. " +
|
||||
$"Total rows deleted: {result.DeletedCounts.Values.Sum()}");
|
||||
|
||||
var okResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
await okResponse.WriteAsJsonAsync(result);
|
||||
return okResponse;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Tenant offboard failed for tenant {tenantId}");
|
||||
result.Success = false;
|
||||
result.Error = ex.Message;
|
||||
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(result);
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all rows in a table where PartitionKey equals the given value.
|
||||
/// Uses batched deletes (max 100 per transaction, same PartitionKey required).
|
||||
/// </summary>
|
||||
private async Task<int> DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(tableName);
|
||||
var filter = $"PartitionKey eq '{partitionKey}'";
|
||||
var entities = tableClient.QueryAsync<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
|
||||
|
||||
int deleted = 0;
|
||||
var batch = new List<TableTransactionAction>();
|
||||
|
||||
await foreach (var entity in entities)
|
||||
{
|
||||
batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity));
|
||||
deleted++;
|
||||
|
||||
if (batch.Count >= 100)
|
||||
{
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to delete rows in {tableName} for partition {partitionKey}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all events for a tenant. Events use PartitionKey = "{tenantId}_{sessionId}",
|
||||
/// so we query with a starts-with filter.
|
||||
/// </summary>
|
||||
private async Task<int> DeleteEventsByTenantPrefixAsync(string normalizedTenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
|
||||
// OData startsWith: PartitionKey ge 'prefix' and PartitionKey lt 'prefix~'
|
||||
var prefix = normalizedTenantId + "_";
|
||||
var filter = $"PartitionKey ge '{prefix}' and PartitionKey lt '{prefix}~'";
|
||||
var entities = tableClient.QueryAsync<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
|
||||
|
||||
int deleted = 0;
|
||||
// Group by PartitionKey because Azure Table batch transactions require same PartitionKey
|
||||
var groups = new Dictionary<string, List<TableEntity>>();
|
||||
|
||||
await foreach (var entity in entities)
|
||||
{
|
||||
if (!groups.ContainsKey(entity.PartitionKey))
|
||||
groups[entity.PartitionKey] = new List<TableEntity>();
|
||||
groups[entity.PartitionKey].Add(entity);
|
||||
deleted++;
|
||||
}
|
||||
|
||||
foreach (var group in groups.Values)
|
||||
{
|
||||
for (int i = 0; i < group.Count; i += 100)
|
||||
{
|
||||
var chunk = group.Skip(i).Take(100).ToList();
|
||||
var batch = chunk.Select(e => new TableTransactionAction(TableTransactionActionType.Delete, e)).ToList();
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to delete events for tenant {normalizedTenantId}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class OffboardResult
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string InitiatedBy { get; set; } = string.Empty;
|
||||
public DateTime InitiatedAt { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public Dictionary<string, int> DeletedCounts { get; set; } = new();
|
||||
}
|
||||
@@ -12,19 +12,23 @@ const PLATFORM_STATS = {
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
const { login, isAuthenticated, isLoading } = useAuth();
|
||||
const { login, isAuthenticated, isLoading, user } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Check for preview mode (for debugging while logged in)
|
||||
const previewMode = searchParams.get("preview") === "true";
|
||||
|
||||
// Redirect to dashboard if already authenticated (unless in preview mode)
|
||||
// Redirect after login: regular users go to /progress, admins go to dashboard
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isLoading && !previewMode) {
|
||||
router.push("/");
|
||||
if (isAuthenticated && !isLoading && !previewMode && user) {
|
||||
if (user.isTenantAdmin || user.isGalacticAdmin) {
|
||||
router.push("/");
|
||||
} else {
|
||||
router.push("/progress");
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, previewMode, router]);
|
||||
}, [isAuthenticated, isLoading, previewMode, user, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -100,8 +100,18 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial data fetch
|
||||
// Redirect regular users (non-admin) to progress portal – they must never see the session list
|
||||
useEffect(() => {
|
||||
if (user && !user.isTenantAdmin && !user.isGalacticAdmin) {
|
||||
router.replace("/progress");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Initial data fetch (only runs for admins)
|
||||
useEffect(() => {
|
||||
if (user && !user.isTenantAdmin && !user.isGalacticAdmin) {
|
||||
return; // regular users are being redirected, don't fetch
|
||||
}
|
||||
// Prevent duplicate fetches in React StrictMode (development double-mounting)
|
||||
if (hasInitialFetch.current) {
|
||||
return;
|
||||
@@ -111,7 +121,7 @@ export default function Home() {
|
||||
// Only fetch sessions on load, no automatic health check
|
||||
fetchSessions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty deps - only run once on mount
|
||||
}, [user]); // re-run once user is known
|
||||
|
||||
// Join tenant group when SignalR is connected (for multi-tenancy)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,7 +33,7 @@ interface TenantAdmin {
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { tenantId } = useTenant();
|
||||
const { getAccessToken, user } = useAuth();
|
||||
const { getAccessToken, user, logout } = useAuth();
|
||||
const [config, setConfig] = useState<TenantConfiguration | null>(null);
|
||||
const [admins, setAdmins] = useState<TenantAdmin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -48,6 +48,12 @@ export default function SettingsPage() {
|
||||
const [adminSearchQuery, setAdminSearchQuery] = useState("");
|
||||
const [currentAdminPage, setCurrentAdminPage] = useState(0);
|
||||
|
||||
// Offboard state
|
||||
const [showOffboardDialog, setShowOffboardDialog] = useState(false);
|
||||
const [offboardConfirmText, setOffboardConfirmText] = useState("");
|
||||
const [offboarding, setOffboarding] = useState(false);
|
||||
const [offboardError, setOffboardError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [manufacturerWhitelist, setManufacturerWhitelist] = useState("Dell*,HP*,Lenovo*,Microsoft Corporation");
|
||||
const [modelWhitelist, setModelWhitelist] = useState("*");
|
||||
@@ -348,6 +354,40 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
|
||||
const handleOffboard = async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
setOffboarding(true);
|
||||
setOffboardError(null);
|
||||
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error('Failed to get access token');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/offboard`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data?.error || `Offboard failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Offboard successful – sign out the user as their admin access is gone
|
||||
logout();
|
||||
} catch (err) {
|
||||
setOffboardError(err instanceof Error ? err.message : 'Offboard failed');
|
||||
setOffboarding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect regular users (non-TenantAdmin) to progress portal
|
||||
if (user && !user.isTenantAdmin && !user.isGalacticAdmin) {
|
||||
router.replace('/progress');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
@@ -845,6 +885,114 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
|
||||
{/* Danger Zone: Tenant Offboarding */}
|
||||
{user?.isTenantAdmin && (
|
||||
<div className="bg-white rounded-lg shadow border-2 border-red-200">
|
||||
<div className="p-6 border-b border-red-100 bg-red-50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-red-900">Danger Zone</h2>
|
||||
<p className="text-sm text-red-600 mt-1">Irreversible and destructive actions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Offboard this Tenant</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Permanently deletes <strong>all data</strong> for this tenant – sessions, events, rules, audit logs, configuration, and all admin accounts including yours. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowOffboardDialog(true); setOffboardConfirmText(""); setOffboardError(null); }}
|
||||
className="ml-6 flex-shrink-0 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Offboard Tenant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Offboard Confirmation Dialog */}
|
||||
{showOffboardDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Tenant Offboarding</h3>
|
||||
<p className="text-sm text-red-600 font-medium">This action is permanent and cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 text-sm text-red-800 space-y-1">
|
||||
<p className="font-semibold">The following will be permanently deleted:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>All enrollment sessions and events</li>
|
||||
<li>All gather and analyze rules</li>
|
||||
<li>Audit logs and usage metrics</li>
|
||||
<li>Tenant configuration</li>
|
||||
<li>All admin accounts (including yours)</li>
|
||||
</ul>
|
||||
<p className="mt-3 font-semibold">After offboarding you will be signed out and lose all access.</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Type <span className="font-bold text-red-600">OFFBOARD</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={offboardConfirmText}
|
||||
onChange={(e) => setOffboardConfirmText(e.target.value)}
|
||||
placeholder="OFFBOARD"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{offboardError && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded p-3 text-sm text-red-800">
|
||||
{offboardError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => { setShowOffboardDialog(false); setOffboardConfirmText(""); setOffboardError(null); }}
|
||||
disabled={offboarding}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOffboard}
|
||||
disabled={offboarding || offboardConfirmText !== "OFFBOARD"}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
{offboarding ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Offboarding...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Permanently Delete All Data</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Info */}
|
||||
{config && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
|
||||
@@ -104,6 +104,78 @@ export default function Navbar() {
|
||||
return `${Math.floor(diffHours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const isTenantAdmin = user?.isTenantAdmin ?? false;
|
||||
|
||||
// Regular users (non-TenantAdmin): show minimal navbar with only Progress Portal
|
||||
if (!isTenantAdmin && !user?.isGalacticAdmin) {
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/progress" className="flex flex-col">
|
||||
<span className="text-2xl font-bold text-blue-600">Autopilot Monitor</span>
|
||||
<span className="text-xs text-gray-500">v1.0.0</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* User Menu */}
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(() => {
|
||||
if (user?.displayName) {
|
||||
const names = user.displayName.split(' ');
|
||||
if (names.length >= 2) {
|
||||
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
||||
}
|
||||
return user.displayName.charAt(0).toUpperCase();
|
||||
}
|
||||
return user?.upn?.charAt(0).toUpperCase() || 'U';
|
||||
})()}
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-start space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-600 flex-shrink-0 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(() => {
|
||||
if (user?.displayName) {
|
||||
const names = user.displayName.split(' ');
|
||||
if (names.length >= 2) {
|
||||
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
||||
}
|
||||
return user.displayName.charAt(0).toUpperCase();
|
||||
}
|
||||
return user?.upn?.charAt(0).toUpperCase() || 'U';
|
||||
})()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{user?.displayName || 'User'}</p>
|
||||
<p className="text-sm text-gray-600 truncate">{user?.upn}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button onClick={() => { logout(); setShowUserMenu(false); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
@@ -17,14 +17,13 @@ const ThemeContext = createContext<ThemeContextType>({
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("light");
|
||||
|
||||
// Initialize from localStorage on mount
|
||||
// Initialize from localStorage on mount, default is always "light"
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "dark" || stored === "light") {
|
||||
setTheme(stored);
|
||||
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
setTheme("dark");
|
||||
}
|
||||
// No system preference fallback – default is always light
|
||||
}, []);
|
||||
|
||||
// Apply class to <html> and persist
|
||||
|
||||
Reference in New Issue
Block a user