mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
added private preview support
This commit is contained in:
@@ -1 +1 @@
|
||||
29
|
||||
30
|
||||
|
||||
@@ -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<AuthFunction> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// CRUD endpoints for managing the Private Preview tenant whitelist.
|
||||
/// All endpoints are Galactic Admin only.
|
||||
/// Temporary — remove after GA.
|
||||
/// </summary>
|
||||
public class PreviewWhitelistFunction
|
||||
{
|
||||
private readonly ILogger<PreviewWhitelistFunction> _logger;
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly GalacticAdminService _galacticAdminService;
|
||||
|
||||
public PreviewWhitelistFunction(
|
||||
ILogger<PreviewWhitelistFunction> logger,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
GalacticAdminService galacticAdminService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_galacticAdminService = galacticAdminService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/preview-whitelist
|
||||
/// Returns all approved tenants.
|
||||
/// </summary>
|
||||
[Function("GetPreviewWhitelist")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/preview-whitelist/{tenantId}
|
||||
/// Approves a tenant for Private Preview.
|
||||
/// </summary>
|
||||
[Function("ApprovePreviewTenant")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/preview-whitelist/{tenantId}
|
||||
/// Revokes a tenant's Private Preview access.
|
||||
/// </summary>
|
||||
[Function("RevokePreviewTenant")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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;
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ builder.Services.AddSingleton<AdminConfigurationService>();
|
||||
builder.Services.AddSingleton<RateLimitService>();
|
||||
builder.Services.AddSingleton<UsageMetricsService>();
|
||||
builder.Services.AddSingleton<GalacticAdminService>();
|
||||
builder.Services.AddSingleton<PreviewWhitelistService>();
|
||||
builder.Services.AddSingleton<TenantAdminsService>();
|
||||
builder.Services.AddSingleton<HealthCheckService>();
|
||||
builder.Services.AddSingleton<GatherRuleService>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PreviewWhitelistService
|
||||
{
|
||||
private readonly TableServiceClient _tableServiceClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PreviewWhitelistService> _logger;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public PreviewWhitelistService(
|
||||
IConfiguration configuration,
|
||||
IMemoryCache cache,
|
||||
ILogger<PreviewWhitelistService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
var connectionString = configuration["AzureTableStorageConnectionString"];
|
||||
_tableServiceClient = new TableServiceClient(connectionString);
|
||||
// Table is initialized centrally by TableInitializerService at startup
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a tenant is approved for Private Preview (cached).
|
||||
/// </summary>
|
||||
public async Task<bool> IsApprovedAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
return false;
|
||||
|
||||
var cacheKey = $"preview:{tenantId}";
|
||||
if (_cache.TryGetValue<bool>(cacheKey, out var approved))
|
||||
return approved;
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
|
||||
var entity = await tableClient.GetEntityAsync<PreviewWhitelistEntity>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves a tenant for Private Preview access.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a tenant's Private Preview access.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all approved tenants (for Galactic Admin overview).
|
||||
/// </summary>
|
||||
public async Task<List<PreviewWhitelistEntity>> GetAllApprovedAsync()
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
|
||||
var results = new List<PreviewWhitelistEntity>();
|
||||
|
||||
await foreach (var entity in tableClient.QueryAsync<PreviewWhitelistEntity>(
|
||||
filter: "RowKey eq 'approved'"))
|
||||
{
|
||||
results.Add(entity);
|
||||
}
|
||||
|
||||
return results.OrderBy(e => e.PartitionKey).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing an approved tenant in the PreviewWhitelist table.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// Returns all table names for initialization
|
||||
/// </summary>
|
||||
@@ -164,7 +167,8 @@ namespace AutopilotMonitor.Shared
|
||||
TenantConfiguration,
|
||||
AdminConfiguration,
|
||||
GalacticAdmins,
|
||||
TenantAdmins
|
||||
TenantAdmins,
|
||||
PreviewWhitelist
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ export default function AdminConfigurationPage() {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const tenantsPerPage = 3;
|
||||
|
||||
// Preview Whitelist state
|
||||
const [previewApproved, setPreviewApproved] = useState<Set<string>>(new Set());
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [togglingPreviewTenant, setTogglingPreviewTenant] = useState<string | null>(null);
|
||||
|
||||
// Admin Management state (for Edit Modal)
|
||||
const [tenantAdmins, setTenantAdmins] = useState<TenantAdmin[]>([]);
|
||||
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<string>(
|
||||
(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
|
||||
</span>
|
||||
)}
|
||||
{previewApproved.has(tenant.tenantId) ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Preview
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||
Waitlist
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Tenant ID: {tenant.tenantId}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTenant(tenant);
|
||||
fetchTenantAdmins(tenant.tenantId);
|
||||
}}
|
||||
className="ml-4 px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
onClick={() => handleTogglePreview(tenant.tenantId)}
|
||||
disabled={togglingPreviewTenant === tenant.tenantId}
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
previewApproved.has(tenant.tenantId)
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{togglingPreviewTenant === tenant.tenantId
|
||||
? "..."
|
||||
: previewApproved.has(tenant.tenantId)
|
||||
? "Revoke"
|
||||
: "Approve"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTenant(tenant);
|
||||
fetchTenantAdmins(tenant.tenantId);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center px-6">
|
||||
<div className="max-w-lg w-full text-center">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center space-x-3 mb-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Autopilot Monitor
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-10">
|
||||
{/* Clock icon */}
|
||||
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-8 h-8 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
Private Preview
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
{previewMessage || "Autopilot Monitor is currently in Private Preview. Your organization is on the waitlist \u2014 we'll notify you when access is granted."}
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-blue-700">
|
||||
Signed in as <span className="font-semibold">{user?.upn}</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
Tenant: {user?.tenantId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-6 py-3 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-400">
|
||||
© 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: UserInfo | null;
|
||||
isLoading: boolean;
|
||||
isPreviewBlocked: boolean;
|
||||
previewMessage: string;
|
||||
login: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
@@ -47,6 +49,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const [user, setUser] = useState<UserInfo | null>(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,
|
||||
|
||||
Reference in New Issue
Block a user