diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index f04c001f..64bb6b74 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -29 +30 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs index b7375b53..cfa680d7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs @@ -19,19 +19,22 @@ public class AuthFunction private readonly TenantConfigurationService _tenantConfigService; private readonly TenantAdminsService _tenantAdminsService; private readonly TableStorageService _storageService; + private readonly PreviewWhitelistService _previewWhitelistService; public AuthFunction( ILogger logger, GalacticAdminService galacticAdminService, TenantConfigurationService tenantConfigService, TenantAdminsService tenantAdminsService, - TableStorageService storageService) + TableStorageService storageService, + PreviewWhitelistService previewWhitelistService) { _logger = logger; _galacticAdminService = galacticAdminService; _tenantConfigService = tenantConfigService; _tenantAdminsService = tenantAdminsService; _storageService = storageService; + _previewWhitelistService = previewWhitelistService; } /// @@ -111,9 +114,24 @@ public class AuthFunction await _tenantConfigService.SaveConfigurationAsync(tenantConfig); } - // Check if user is galactic admin + // Check if user is galactic admin (must happen before preview gate) var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); + // Preview gate: only approved tenants get full portal access. + // Galactic Admins bypass the gate (they need access to manage the whitelist). + if (!isGalacticAdmin && !await _previewWhitelistService.IsApprovedAsync(tenantId)) + { + _logger.LogInformation("Tenant {TenantId} blocked by preview gate (user: {Upn})", tenantId, upn); + + var previewResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await previewResponse.WriteAsJsonAsync(new + { + error = "PrivatePreview", + message = "Autopilot Monitor is currently in Private Preview. Your organization is on the waitlist \u2014 we'll notify you when access is granted." + }); + return previewResponse; + } + // Check if user is tenant admin bool isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/PreviewWhitelistFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/PreviewWhitelistFunction.cs new file mode 100644 index 00000000..cbb14a8f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/PreviewWhitelistFunction.cs @@ -0,0 +1,131 @@ +using System.Net; +using AutopilotMonitor.Functions.Extensions; +using AutopilotMonitor.Functions.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions; + +/// +/// CRUD endpoints for managing the Private Preview tenant whitelist. +/// All endpoints are Galactic Admin only. +/// Temporary — remove after GA. +/// +public class PreviewWhitelistFunction +{ + private readonly ILogger _logger; + private readonly PreviewWhitelistService _previewWhitelistService; + private readonly GalacticAdminService _galacticAdminService; + + public PreviewWhitelistFunction( + ILogger logger, + PreviewWhitelistService previewWhitelistService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _previewWhitelistService = previewWhitelistService; + _galacticAdminService = galacticAdminService; + } + + /// + /// GET /api/preview-whitelist + /// Returns all approved tenants. + /// + [Function("GetPreviewWhitelist")] + [Authorize] + public async Task GetAll( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "preview-whitelist")] HttpRequestData req, + FunctionContext context) + { + var principal = context.GetUser(); + if (principal == null) + return req.CreateResponse(HttpStatusCode.Unauthorized); + + var upn = principal.GetUserPrincipalName(); + if (!await _galacticAdminService.IsGalacticAdminAsync(upn)) + { + var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); + await forbidden.WriteAsJsonAsync(new { error = "Only Galactic Admins can access this endpoint" }); + return forbidden; + } + + var approved = await _previewWhitelistService.GetAllApprovedAsync(); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { tenants = approved }); + return response; + } + + /// + /// POST /api/preview-whitelist/{tenantId} + /// Approves a tenant for Private Preview. + /// + [Function("ApprovePreviewTenant")] + [Authorize] + public async Task Approve( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "preview-whitelist/{tenantId}")] HttpRequestData req, + string tenantId, + FunctionContext context) + { + var principal = context.GetUser(); + if (principal == null) + return req.CreateResponse(HttpStatusCode.Unauthorized); + + var upn = principal.GetUserPrincipalName(); + if (!await _galacticAdminService.IsGalacticAdminAsync(upn)) + { + var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); + await forbidden.WriteAsJsonAsync(new { error = "Only Galactic Admins can approve tenants" }); + return forbidden; + } + + if (string.IsNullOrWhiteSpace(tenantId)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = "tenantId is required" }); + return bad; + } + + await _previewWhitelistService.ApproveAsync(tenantId, upn!); + + _logger.LogInformation("Preview tenant approved: {TenantId} by {Upn}", tenantId, upn); + + var response = req.CreateResponse(HttpStatusCode.Created); + await response.WriteAsJsonAsync(new { message = "Tenant approved for preview", tenantId }); + return response; + } + + /// + /// DELETE /api/preview-whitelist/{tenantId} + /// Revokes a tenant's Private Preview access. + /// + [Function("RevokePreviewTenant")] + [Authorize] + public async Task Revoke( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "preview-whitelist/{tenantId}")] HttpRequestData req, + string tenantId, + FunctionContext context) + { + var principal = context.GetUser(); + if (principal == null) + return req.CreateResponse(HttpStatusCode.Unauthorized); + + var upn = principal.GetUserPrincipalName(); + if (!await _galacticAdminService.IsGalacticAdminAsync(upn)) + { + var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); + await forbidden.WriteAsJsonAsync(new { error = "Only Galactic Admins can revoke tenant access" }); + return forbidden; + } + + await _previewWhitelistService.RevokeAsync(tenantId); + + _logger.LogInformation("Preview tenant revoked: {TenantId} by {Upn}", tenantId, upn); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { message = "Tenant removed from preview", tenantId }); + return response; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 91ecda24..a6ab8967 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -99,6 +99,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs new file mode 100644 index 00000000..3325504f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs @@ -0,0 +1,130 @@ +using AutopilotMonitor.Shared; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// Service for managing the Private Preview tenant whitelist. +/// Tenants in this list are allowed full portal access; others see a waitlist page. +/// Temporary — remove after GA. +/// +public class PreviewWhitelistService +{ + private readonly TableServiceClient _tableServiceClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + public PreviewWhitelistService( + IConfiguration configuration, + IMemoryCache cache, + ILogger logger) + { + _cache = cache; + _logger = logger; + var connectionString = configuration["AzureTableStorageConnectionString"]; + _tableServiceClient = new TableServiceClient(connectionString); + // Table is initialized centrally by TableInitializerService at startup + } + + /// + /// Checks whether a tenant is approved for Private Preview (cached). + /// + public async Task IsApprovedAsync(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + return false; + + var cacheKey = $"preview:{tenantId}"; + if (_cache.TryGetValue(cacheKey, out var approved)) + return approved; + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); + var entity = await tableClient.GetEntityAsync(tenantId, "approved"); + var result = entity?.Value != null; + + _cache.Set(cacheKey, result, CacheDuration); + return result; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _cache.Set(cacheKey, false, CacheDuration); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking preview whitelist for tenant {TenantId}", tenantId); + // Fail-closed: if we can't check, deny access + return false; + } + } + + /// + /// Approves a tenant for Private Preview access. + /// + public async Task ApproveAsync(string tenantId, string approvedBy) + { + var entity = new PreviewWhitelistEntity + { + PartitionKey = tenantId, + RowKey = "approved", + ApprovedAt = DateTime.UtcNow, + ApprovedBy = approvedBy + }; + + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); + await tableClient.UpsertEntityAsync(entity); + + _cache.Remove($"preview:{tenantId}"); + _logger.LogInformation("Tenant {TenantId} approved for preview by {ApprovedBy}", tenantId, approvedBy); + } + + /// + /// Revokes a tenant's Private Preview access. + /// + public async Task RevokeAsync(string tenantId) + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); + await tableClient.DeleteEntityAsync(tenantId, "approved"); + + _cache.Remove($"preview:{tenantId}"); + _logger.LogInformation("Tenant {TenantId} revoked from preview", tenantId); + } + + /// + /// Returns all approved tenants (for Galactic Admin overview). + /// + public async Task> GetAllApprovedAsync() + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); + var results = new List(); + + await foreach (var entity in tableClient.QueryAsync( + filter: "RowKey eq 'approved'")) + { + results.Add(entity); + } + + return results.OrderBy(e => e.PartitionKey).ToList(); + } +} + +/// +/// Entity representing an approved tenant in the PreviewWhitelist table. +/// +public class PreviewWhitelistEntity : ITableEntity +{ + public string PartitionKey { get; set; } = string.Empty; // TenantId + public string RowKey { get; set; } = "approved"; + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } + + public DateTime ApprovedAt { get; set; } + public string ApprovedBy { get; set; } = string.Empty; +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 74533cee..ae64430b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -146,6 +146,9 @@ namespace AutopilotMonitor.Shared public const string GalacticAdmins = "GalacticAdmins"; public const string TenantAdmins = "TenantAdmins"; + // Preview gating (temporary — remove after GA) + public const string PreviewWhitelist = "PreviewWhitelist"; + /// /// Returns all table names for initialization /// @@ -164,7 +167,8 @@ namespace AutopilotMonitor.Shared TenantConfiguration, AdminConfiguration, GalacticAdmins, - TenantAdmins + TenantAdmins, + PreviewWhitelist }; } } diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx index e0418440..cdb36a0c 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -66,6 +66,11 @@ export default function AdminConfigurationPage() { const [currentPage, setCurrentPage] = useState(0); const tenantsPerPage = 3; + // Preview Whitelist state + const [previewApproved, setPreviewApproved] = useState>(new Set()); + const [loadingPreview, setLoadingPreview] = useState(false); + const [togglingPreviewTenant, setTogglingPreviewTenant] = useState(null); + // Admin Management state (for Edit Modal) const [tenantAdmins, setTenantAdmins] = useState([]); const [loadingAdmins, setLoadingAdmins] = useState(false); @@ -127,7 +132,7 @@ export default function AdminConfigurationPage() { fetchAdminConfig(); }, [galacticAdminMode]); - // Fetch all tenant configurations + // Fetch all tenant configurations + preview whitelist useEffect(() => { if (!galacticAdminMode) return; @@ -140,18 +145,29 @@ export default function AdminConfigurationPage() { throw new Error('Failed to get access token'); } - const response = await fetch(`${API_BASE_URL}/api/config/all`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); + const [tenantsRes, previewRes] = await Promise.all([ + fetch(`${API_BASE_URL}/api/config/all`, { + headers: { 'Authorization': `Bearer ${token}` } + }), + fetch(`${API_BASE_URL}/api/preview-whitelist`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + ]); - if (!response.ok) { - throw new Error(`Failed to load tenants: ${response.statusText}`); + if (!tenantsRes.ok) { + throw new Error(`Failed to load tenants: ${tenantsRes.statusText}`); } - const data: TenantConfiguration[] = await response.json(); + const data: TenantConfiguration[] = await tenantsRes.json(); setTenants(data); + + if (previewRes.ok) { + const previewData = await previewRes.json(); + const approvedIds = new Set( + (previewData.tenants || []).map((t: { partitionKey: string }) => t.partitionKey) + ); + setPreviewApproved(approvedIds); + } } catch (err) { console.error("Error fetching tenants:", err); setError(err instanceof Error ? err.message : "Failed to load tenants"); @@ -343,6 +359,52 @@ export default function AdminConfigurationPage() { } }; + const handleTogglePreview = async (tenantId: string) => { + try { + setTogglingPreviewTenant(tenantId); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const isCurrentlyApproved = previewApproved.has(tenantId); + + const response = await fetch(`${API_BASE_URL}/api/preview-whitelist/${tenantId}`, { + method: isCurrentlyApproved ? "DELETE" : "POST", + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Failed to update preview access: ${response.statusText}`); + } + + setPreviewApproved(prev => { + const next = new Set(prev); + if (isCurrentlyApproved) { + next.delete(tenantId); + } else { + next.add(tenantId); + } + return next; + }); + + setSuccessMessage( + isCurrentlyApproved + ? `Preview access revoked for tenant ${tenantId}` + : `Preview access granted for tenant ${tenantId}` + ); + setTimeout(() => setSuccessMessage(null), 4000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update preview access"); + } finally { + setTogglingPreviewTenant(null); + } + }; + // Fetch tenant admins for editing const fetchTenantAdmins = async (tenantId: string) => { try { @@ -645,20 +707,46 @@ export default function AdminConfigurationPage() { Suspended )} + {previewApproved.has(tenant.tenantId) ? ( + + Preview + + ) : ( + + Waitlist + + )}

Tenant ID: {tenant.tenantId}

- +
+ + +
))} diff --git a/src/Web/autopilot-monitor-web/app/landing/page.tsx b/src/Web/autopilot-monitor-web/app/landing/page.tsx index b8ba4ed7..882f07d0 100644 --- a/src/Web/autopilot-monitor-web/app/landing/page.tsx +++ b/src/Web/autopilot-monitor-web/app/landing/page.tsx @@ -12,19 +12,21 @@ const PLATFORM_STATS = { }; export default function LandingPage() { - const { login, isAuthenticated, isLoading, user } = useAuth(); + const { login, isAuthenticated, isLoading, user, isPreviewBlocked } = useAuth(); const router = useRouter(); - // Redirect after login: regular users go to /progress, admins go to dashboard + // Redirect after login: preview-blocked → /preview, admins → /, users → /progress useEffect(() => { if (isAuthenticated && !isLoading && user) { - if (user.isTenantAdmin || user.isGalacticAdmin) { + if (isPreviewBlocked) { + router.push("/preview"); + } else if (user.isTenantAdmin || user.isGalacticAdmin) { router.push("/"); } else { router.push("/progress"); } } - }, [isAuthenticated, isLoading, user, router]); + }, [isAuthenticated, isLoading, user, isPreviewBlocked, router]); if (isLoading) { return ( diff --git a/src/Web/autopilot-monitor-web/app/preview/page.tsx b/src/Web/autopilot-monitor-web/app/preview/page.tsx new file mode 100644 index 00000000..a780af0a --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/preview/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useAuth } from "../../contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function PreviewPage() { + const { isAuthenticated, isLoading, user, isPreviewBlocked, previewMessage, logout } = useAuth(); + const router = useRouter(); + + // If not preview-blocked (e.g. approved tenant navigates here), redirect away + useEffect(() => { + if (!isLoading && isAuthenticated && user && !isPreviewBlocked) { + if (user.isTenantAdmin || user.isGalacticAdmin) { + router.push("/"); + } else { + router.push("/progress"); + } + } + if (!isLoading && !isAuthenticated) { + router.push("/landing"); + } + }, [isAuthenticated, isLoading, user, isPreviewBlocked, router]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+ {/* Logo */} +
+
+ + + +
+ + Autopilot Monitor + +
+ + {/* Card */} +
+ {/* Clock icon */} +
+ + + +
+ +

+ Private Preview +

+ +

+ {previewMessage || "Autopilot Monitor is currently in Private Preview. Your organization is on the waitlist \u2014 we'll notify you when access is granted."} +

+ +
+

+ Signed in as {user?.upn} +

+

+ Tenant: {user?.tenantId} +

+
+ + +
+ +

+ © 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity. +

+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index 798a8303..677593d5 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -30,6 +30,8 @@ interface AuthContextType { isAuthenticated: boolean; user: UserInfo | null; isLoading: boolean; + isPreviewBlocked: boolean; + previewMessage: string; login: () => Promise; logout: () => Promise; getAccessToken: () => Promise; @@ -47,6 +49,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { const isAuthenticated = useIsAuthenticated(); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isPreviewBlocked, setPreviewBlocked] = useState(false); + const [previewMessage, setPreviewMessage] = useState(''); // Handle SSR - if we're on the server, MSAL won't initialize // Set loading to false immediately on mount in browser @@ -75,17 +79,28 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { }); if (!response.ok) { - // Check for tenant suspension if (response.status === 403) { const errorData = await response.json(); if (errorData.error === 'TenantSuspended') { console.error('[Auth] Tenant suspended:', errorData.message); - // Display error to user alert(`Access Denied\n\n${errorData.message}`); - // Logout user await instance.logoutRedirect({ account }); return null; } + if (errorData.error === 'PrivatePreview') { + console.log('[Auth] Tenant not yet approved for preview'); + setPreviewBlocked(true); + setPreviewMessage(errorData.message || 'Your organization is on the waitlist.'); + // Return basic user info so the user stays logged in but sees the preview page + return { + displayName: account.name || '', + upn: account.username || '', + tenantId: account.tenantId || '', + objectId: account.homeAccountId || '', + isGalacticAdmin: false, + isTenantAdmin: false, + }; + } } throw new Error(`Failed to fetch user info: ${response.statusText}`); } @@ -231,6 +246,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { isAuthenticated, user, isLoading, + isPreviewBlocked, + previewMessage, login, logout, getAccessToken,