Added new modules and updated existing logic
This commit is contained in:
@@ -31,7 +31,7 @@ export default {
|
||||
:left-drawer-open="leftDrawer"
|
||||
@toggle-left-drawer="leftDrawer = !leftDrawer"
|
||||
@clear-highlights="clearHighlights"
|
||||
/>
|
||||
></app-header>
|
||||
|
||||
<left-drawer
|
||||
:model-value="leftDrawer"
|
||||
@@ -52,7 +52,7 @@ export default {
|
||||
@update:crosshair-active="crosshairActive = $event"
|
||||
@simulate-wss-lock="simulateWssLock"
|
||||
@reset-to-today="resetToToday"
|
||||
/>
|
||||
></left-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<q-page class="q-pa-none">
|
||||
@@ -86,7 +86,6 @@ export default {
|
||||
<div class="left-col">
|
||||
<div class="grid-search-container">
|
||||
<q-input v-model="search" debounce="300" dense outlined rounded placeholder="Quick search..." class="full-width" bg-color="white" clearable>
|
||||
|
||||
<template v-slot:prepend><q-icon name="search" size="xs" color="grey-5"></q-icon></template>
|
||||
</q-input>
|
||||
<q-btn flat round dense icon="filter_alt" :color="isFilterActive ? 'indigo-10' : 'grey-6'" :class="isFilterActive ? 'filter-btn-active' : ''" size="sm" @click="filterDrawer = !filterDrawer">
|
||||
@@ -144,7 +143,7 @@ export default {
|
||||
|
||||
<!-- VIRTUAL SCROLL IMPLEMENTATION (Simplified) -->
|
||||
<div v-if="!loading">
|
||||
<div v-for="agent in filteredAgents.slice(0, 5)" :key="agent.id" class="planner-row planner-row-item" :class="{'row-highlighted': highlightedRowId === agent.id, 'crosshair-enabled': crosshairActive}">
|
||||
<div v-for="agent in filteredAgents" :key="agent.id" class="planner-row planner-row-item" :class="{'row-highlighted': highlightedRowId === agent.id, 'crosshair-enabled': crosshairActive}">
|
||||
<div class="left-col border-b cursor-pointer agent-row-hover-trigger relative-position" :class="[isCompact ? 'cell-compact' : '']" @click="openProfile(agent)">
|
||||
<q-avatar :size="isCompact ? '24px' : '32px'" class="shadow-1"><img :src="agent.avatar"></q-avatar>
|
||||
<div class="q-ml-sm overflow-hidden col">
|
||||
|
||||
@@ -6,9 +6,9 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using HotlinePlanner.Services;
|
||||
using System.Linq;
|
||||
using HotlinePlanner.Data;
|
||||
using HotlinePlanner.Models;
|
||||
|
||||
namespace HotlinePlanner.Controllers
|
||||
{
|
||||
@@ -17,36 +17,39 @@ namespace HotlinePlanner.Controllers
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IRefreshTokenStore _tokenStore;
|
||||
private readonly HotlinePlannerDbContext _context;
|
||||
|
||||
public AccountController(IMemoryCache cache, IHttpClientFactory httpClientFactory, IRefreshTokenStore tokenStore)
|
||||
public AccountController(
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IRefreshTokenStore tokenStore,
|
||||
HotlinePlannerDbContext context)
|
||||
{
|
||||
_cache = cache;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_tokenStore = tokenStore;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public IActionResult ExternalLogin(string provider, string returnUrl = "/")
|
||||
{
|
||||
// Configure the authentication properties
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
// Set the redirect URI to the home page after the external login is complete.
|
||||
RedirectUri = Url.Action("Index", "Home")
|
||||
};
|
||||
|
||||
// Add the "prompt" parameter to the authentication request.
|
||||
// This will force the user to select an account every time they log in.
|
||||
properties.Parameters.Add("prompt", "select_account");
|
||||
|
||||
return new ChallengeResult(provider, properties);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public IActionResult Logout()
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
// External providers (Google/MicrosoftAccount) do not support SignOutAsync.
|
||||
// Clear the local auth cookie only.
|
||||
var userId = GetUserId();
|
||||
var provider = User.FindFirst("app.provider")?.Value;
|
||||
|
||||
await LogEvent("UserAction", $"User logged out", "Info", null, userId);
|
||||
|
||||
RemoveCachedAccessToken();
|
||||
var redirectUri = Url.Action("Index", "Home") ?? "/";
|
||||
var properties = new AuthenticationProperties { RedirectUri = redirectUri };
|
||||
@@ -55,13 +58,10 @@ namespace HotlinePlanner.Controllers
|
||||
|
||||
[Authorize]
|
||||
[HttpPost]
|
||||
public IActionResult ToggleSubscription()
|
||||
public async Task<IActionResult> ToggleSubscription()
|
||||
{
|
||||
var provider = User.FindFirst("app.provider")?.Value ?? "Microsoft";
|
||||
var userId =
|
||||
User.FindFirst("oid")?.Value ??
|
||||
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ??
|
||||
User.FindFirst("sub")?.Value;
|
||||
var userId = GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
@@ -78,7 +78,11 @@ namespace HotlinePlanner.Controllers
|
||||
return BadRequest("No refresh token stored yet. Please re-login.");
|
||||
}
|
||||
|
||||
_tokenStore.Upsert(record with { Enabled = !record.Enabled, UpdatedUtc = DateTime.UtcNow });
|
||||
var newState = !record.Enabled;
|
||||
_tokenStore.Upsert(record with { Enabled = newState, UpdatedUtc = DateTime.UtcNow });
|
||||
|
||||
await LogEvent("UserAction", $"User {(newState ? "activated" : "deactivated")} background worker", "Info", record.Tenant, userId);
|
||||
|
||||
return RedirectToAction("Index", "Home");
|
||||
}
|
||||
|
||||
@@ -87,19 +91,11 @@ namespace HotlinePlanner.Controllers
|
||||
public async Task<IActionResult> CreateCalendarEvent()
|
||||
{
|
||||
var provider = User.FindFirst("app.provider")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
return BadRequest("Unknown provider.");
|
||||
}
|
||||
var userId = GetUserId();
|
||||
|
||||
var userId =
|
||||
User.FindFirst("oid")?.Value ??
|
||||
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ??
|
||||
User.FindFirst("sub")?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return BadRequest("Missing user id.");
|
||||
return BadRequest("User identification failed.");
|
||||
}
|
||||
|
||||
if (!_cache.TryGetValue($"at:{provider}:{userId}", out string? accessToken) ||
|
||||
@@ -113,44 +109,44 @@ namespace HotlinePlanner.Controllers
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var endUtc = nowUtc.AddHours(1);
|
||||
bool success = false;
|
||||
|
||||
if (string.Equals(provider, "Microsoft", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
subject = "Hotline Planner event",
|
||||
body = new { contentType = "Text", content = "This event was created by Hotline Planner." },
|
||||
subject = "Hotline Planner manual event",
|
||||
body = new { contentType = "Text", content = "This event was created manually from Hotline Planner UI." },
|
||||
start = new { dateTime = nowUtc.ToString("o"), timeZone = "UTC" },
|
||||
end = new { dateTime = endUtc.ToString("o"), timeZone = "UTC" }
|
||||
};
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("https://graph.microsoft.com/v1.0/me/events", content);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)response.StatusCode);
|
||||
}
|
||||
success = response.IsSuccessStatusCode;
|
||||
}
|
||||
else if (string.Equals(provider, "Google", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
summary = "Hotline Planner event",
|
||||
description = "This event was created by Hotline Planner.",
|
||||
summary = "Hotline Planner manual event",
|
||||
description = "This event was created manually from Hotline Planner UI.",
|
||||
start = new { dateTime = nowUtc.ToString("o"), timeZone = "UTC" },
|
||||
end = new { dateTime = endUtc.ToString("o"), timeZone = "UTC" }
|
||||
};
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("https://www.googleapis.com/calendar/v3/calendars/primary/events", content);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)response.StatusCode);
|
||||
success = response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
await LogEvent("UserAction", "User created manual calendar event", "Info", null, userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest("Unsupported provider.");
|
||||
await LogEvent("UserAction", "User failed to create manual calendar event", "Warning", null, userId);
|
||||
}
|
||||
|
||||
return RedirectToAction("Index", "Home");
|
||||
@@ -160,16 +156,41 @@ namespace HotlinePlanner.Controllers
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> MicrosoftPhoto()
|
||||
{
|
||||
var userId =
|
||||
User.FindFirst("oid")?.Value ??
|
||||
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ??
|
||||
User.FindFirst("sub")?.Value;
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
// 1. Check Database first
|
||||
var profile = _context.UserProfiles.FirstOrDefault(p => p.UserId == userId);
|
||||
if (profile != null && !string.IsNullOrWhiteSpace(profile.PictureBase64))
|
||||
{
|
||||
return NotFound();
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(profile.PictureBase64);
|
||||
return File(bytes, "image/jpeg");
|
||||
}
|
||||
catch { /* If corrupt, fallback to fetching */ }
|
||||
}
|
||||
|
||||
// 2. Fallback: Fetch from Microsoft Graph
|
||||
return await FetchAndStoreMicrosoftPhoto(userId);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SyncProfilePicture()
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return BadRequest("User ID not found.");
|
||||
|
||||
await FetchAndStoreMicrosoftPhoto(userId);
|
||||
|
||||
await LogEvent("UserAction", "User refreshed profile picture from Microsoft", "Info", null, userId);
|
||||
|
||||
return RedirectToAction("Index", "Home");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> FetchAndStoreMicrosoftPhoto(string userId)
|
||||
{
|
||||
if (!_cache.TryGetValue($"at:Microsoft:{userId}", out string? accessToken) ||
|
||||
string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
@@ -181,34 +202,133 @@ namespace HotlinePlanner.Controllers
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
if (!response.IsSuccessStatusCode) return NoContent();
|
||||
|
||||
var photoBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var base64 = Convert.ToBase64String(photoBytes);
|
||||
|
||||
// Store in DB
|
||||
var existingProfile = _context.UserProfiles.FirstOrDefault(p => p.UserId == userId);
|
||||
if (existingProfile != null)
|
||||
{
|
||||
existingProfile.PictureBase64 = base64;
|
||||
existingProfile.LastUpdated = DateTime.UtcNow;
|
||||
_context.UserProfiles.Update(existingProfile);
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.UserProfiles.Add(new UserProfile
|
||||
{
|
||||
UserId = userId,
|
||||
UserEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ??
|
||||
User.FindFirst("preferred_username")?.Value,
|
||||
PictureBase64 = base64,
|
||||
LastUpdated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return File(photoBytes, "image/jpeg");
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet]
|
||||
public IActionResult EventLogs() => View();
|
||||
|
||||
[Authorize]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetEventLogsData(int page = 1, int rowsPerPage = 10, string? filter = null)
|
||||
{
|
||||
var query = _context.EventLogs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
var lowerFilter = filter.ToLower();
|
||||
query = query.Where(l =>
|
||||
l.Message.ToLower().Contains(lowerFilter) ||
|
||||
l.Category.ToLower().Contains(lowerFilter) ||
|
||||
l.Level.ToLower().Contains(lowerFilter) ||
|
||||
(l.UserId != null && l.UserId.ToLower().Contains(lowerFilter))
|
||||
);
|
||||
}
|
||||
|
||||
var totalItems = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.CountAsync(query);
|
||||
var items = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(
|
||||
query.OrderByDescending(l => l.Timestamp)
|
||||
.Skip((page - 1) * rowsPerPage)
|
||||
.Take(rowsPerPage)
|
||||
);
|
||||
|
||||
return Json(new { items, totalItems });
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet]
|
||||
public IActionResult Tokens() => View();
|
||||
|
||||
[Authorize]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTokensData(int page = 1, int rowsPerPage = 10, string? filter = null)
|
||||
{
|
||||
var query = _context.Tokens.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
var lowerFilter = filter.ToLower();
|
||||
query = query.Where(t =>
|
||||
t.UserId.ToLower().Contains(lowerFilter) ||
|
||||
t.Tenant.ToLower().Contains(lowerFilter) ||
|
||||
t.Provider.ToLower().Contains(lowerFilter)
|
||||
);
|
||||
}
|
||||
|
||||
var totalItems = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.CountAsync(query);
|
||||
var items = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(
|
||||
query.OrderByDescending(t => t.LastUpdated)
|
||||
.Skip((page - 1) * rowsPerPage)
|
||||
.Take(rowsPerPage)
|
||||
);
|
||||
|
||||
return Json(new { items, totalItems });
|
||||
}
|
||||
|
||||
private string? GetUserId()
|
||||
{
|
||||
return User.FindFirst("oid")?.Value ??
|
||||
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ??
|
||||
User.FindFirst("sub")?.Value;
|
||||
}
|
||||
|
||||
private void RemoveCachedAccessToken()
|
||||
{
|
||||
var provider = User.FindFirst("app.provider")?.Value;
|
||||
var userId =
|
||||
User.FindFirst("oid")?.Value ??
|
||||
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ??
|
||||
User.FindFirst("sub")?.Value;
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(provider) && !string.IsNullOrWhiteSpace(userId))
|
||||
var provider = User.FindFirst("app.provider")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
_cache.Remove($"at:{provider}:{userId}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
_cache.Remove($"at:Microsoft:{userId}");
|
||||
_cache.Remove($"at:Google:{userId}");
|
||||
}
|
||||
|
||||
private async Task LogEvent(string category, string message, string level = "Info", string? tenant = null, string? userId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_context.EventLogs.Add(new EventLog
|
||||
{
|
||||
Category = category,
|
||||
Message = message,
|
||||
Level = level,
|
||||
Tenant = tenant,
|
||||
UserId = userId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch { /* Ignore logging failures to prevent blocking main actions */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using HotlinePlanner.Models;
|
||||
|
||||
namespace HotlinePlanner.Data
|
||||
{
|
||||
public class HotlinePlannerDbContext : DbContext
|
||||
{
|
||||
public HotlinePlannerDbContext(DbContextOptions<HotlinePlannerDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<Token> Tokens { get; set; } = null!;
|
||||
public DbSet<UserProfile> UserProfiles { get; set; } = null!;
|
||||
public DbSet<EventLog> EventLogs { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Token>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Tenant).IsRequired();
|
||||
entity.Property(e => e.UserId).IsRequired();
|
||||
entity.Property(e => e.EncryptedToken).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EventLog>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Level).IsRequired();
|
||||
entity.Property(e => e.Category).IsRequired();
|
||||
entity.Property(e => e.Message).IsRequired();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
@@ -10,7 +10,16 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.0-rc.1.24452.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="9.0.0-rc.1.24452.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.0-rc.1.24452.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Graph" Version="5.102.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace HotlinePlanner.Models
|
||||
{
|
||||
public class EventLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Required]
|
||||
public string Level { get; set; } = "Info"; // Info, Warning, Error
|
||||
|
||||
[Required]
|
||||
public string Category { get; set; } = string.Empty; // Auth, BackgroundWorker, UserAction
|
||||
|
||||
[Required]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? UserId { get; set; }
|
||||
|
||||
public string? Payload { get; set; } // JSON or extra details
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HotlinePlanner.Models
|
||||
{
|
||||
[Table("Tokens_v2")]
|
||||
public class Token
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Required]
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
public string? UserEmail { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string EncryptedToken { get; set; } = string.Empty;
|
||||
|
||||
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HotlinePlanner.Models
|
||||
{
|
||||
[Table("UserProfiles")]
|
||||
public class UserProfile
|
||||
{
|
||||
[Key]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
public string? UserEmail { get; set; }
|
||||
|
||||
public string? PictureBase64 { get; set; }
|
||||
|
||||
public DateTime LastUpdated { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,30 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using HotlinePlanner.Services;
|
||||
using System.Linq;
|
||||
using HotlinePlanner.Data;
|
||||
using HotlinePlanner.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
Console.WriteLine("[STARTUP] Hotline Planner Identity Engine Starting...");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllersWithViews();
|
||||
|
||||
// Database configuration
|
||||
builder.Services.AddDbContext<HotlinePlannerDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Security and Encryption
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddSingleton<ITokenEncryptionService, TokenEncryptionService>();
|
||||
|
||||
// Register IHttpClientFactory
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<IRefreshTokenStore, FileRefreshTokenStore>();
|
||||
builder.Services.AddScoped<IRefreshTokenStore, DatabaseRefreshTokenStore>();
|
||||
builder.Services.AddSingleton<CalendarWorkerStatus>();
|
||||
builder.Services.AddHostedService<CalendarWorker>();
|
||||
|
||||
@@ -93,28 +106,85 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(expiresInSec > 60 ? expiresInSec - 30 : 300)
|
||||
};
|
||||
cache.Set($"at:Microsoft:{userId}", accessToken, cacheEntryOptions);
|
||||
var tidClaim = principal?.FindFirst("tid") ??
|
||||
principal?.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid") ??
|
||||
principal?.FindFirst("utid") ??
|
||||
principal?.FindFirst("tenantid");
|
||||
|
||||
var rawTenant = tidClaim?.Value;
|
||||
|
||||
// Try to get from token response parameters if claim is missing
|
||||
if (string.IsNullOrWhiteSpace(rawTenant) && context.TokenEndpointResponse?.Parameters.TryGetValue("tenant_id", out var tidParam) == true)
|
||||
{
|
||||
rawTenant = tidParam?.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawTenant)) rawTenant = "common";
|
||||
|
||||
// Fallback to issuer URL parsing
|
||||
if (rawTenant == "common" && principal?.FindFirst("iss")?.Value is string iss)
|
||||
{
|
||||
if (iss.Contains("login.microsoftonline.com") || iss.Contains("sts.windows.net"))
|
||||
{
|
||||
var parts = iss.TrimEnd('/').Split('/');
|
||||
// For login.microsoftonline.com/GUID/v2.0 -> GUID is at parts[^2]
|
||||
// For sts.windows.net/GUID/ -> GUID is at parts[^1]
|
||||
if (parts.Length > 0)
|
||||
{
|
||||
var lastPart = parts[^1];
|
||||
if (Guid.TryParse(lastPart, out _)) rawTenant = lastPart;
|
||||
else if (parts.Length > 1 && Guid.TryParse(parts[^2], out _)) rawTenant = parts[^2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tenant = (rawTenant == "9188040d-6c67-4c5b-b112-36a304b66dad")
|
||||
? "Personal / MSA"
|
||||
: rawTenant;
|
||||
|
||||
var userEmail = principal?.FindFirst(ClaimTypes.Email)?.Value ??
|
||||
principal?.FindFirst("preferred_username")?.Value ??
|
||||
principal?.FindFirst("upn")?.Value ??
|
||||
principal?.FindFirst(ClaimTypes.Upn)?.Value;
|
||||
|
||||
Console.WriteLine($"[AUTH DEBUG] Microsoft login: User={userEmail}, RawTenant={rawTenant}, MappedTenant={tenant}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
var existing = tokenStore.GetAll().FirstOrDefault(r =>
|
||||
var existingRecord = tokenStore.GetAll().FirstOrDefault(r =>
|
||||
string.Equals(r.Provider, "Microsoft", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(r.UserId, userId, StringComparison.Ordinal));
|
||||
|
||||
tokenStore.Upsert(new RefreshTokenRecord
|
||||
{
|
||||
Provider = "Microsoft",
|
||||
Tenant = tenant,
|
||||
UserId = userId,
|
||||
UserEmail = userEmail,
|
||||
RefreshToken = refreshToken,
|
||||
UpdatedUtc = DateTime.UtcNow,
|
||||
Enabled = existing?.Enabled ?? false
|
||||
Enabled = existingRecord?.Enabled ?? true
|
||||
});
|
||||
}
|
||||
|
||||
if (principal?.Identity is ClaimsIdentity identity)
|
||||
{
|
||||
var photoUrl = "/Account/MicrosoftPhoto";
|
||||
identity.AddClaim(new Claim("app.picture", photoUrl));
|
||||
identity.AddClaim(new Claim("app.picture", "/Account/MicrosoftPhoto"));
|
||||
identity.AddClaim(new Claim("app.provider", "Microsoft"));
|
||||
}
|
||||
|
||||
var db = context.HttpContext.RequestServices.GetRequiredService<HotlinePlannerDbContext>();
|
||||
db.EventLogs.Add(new EventLog
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Level = "Debug",
|
||||
Category = "Identity",
|
||||
Tenant = tenant,
|
||||
UserId = userId,
|
||||
Message = $"User '{userEmail}' logged in. Claims found: {string.Join(", ", principal?.Claims.Select(c => $"{c.Type}={c.Value}") ?? Array.Empty<string>())}"
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
@@ -137,8 +207,8 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
var pictureUrl = context.User.GetProperty("picture").GetString();
|
||||
if (!string.IsNullOrEmpty(pictureUrl))
|
||||
{
|
||||
var claimsIdentity = (ClaimsIdentity)context.Principal.Identity;
|
||||
|
||||
if (context.Principal?.Identity is ClaimsIdentity claimsIdentity)
|
||||
{
|
||||
// Remove the old 'picture' claim if it exists, to avoid conflicts.
|
||||
var oldPictureClaim = claimsIdentity.FindFirst(ClaimTypes.Uri);
|
||||
if (oldPictureClaim != null) { claimsIdentity.RemoveClaim(oldPictureClaim); }
|
||||
@@ -146,6 +216,7 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
// Store the URL instead of base64 to keep cookies small.
|
||||
claimsIdentity.AddClaim(new Claim("app.picture", pictureUrl));
|
||||
}
|
||||
}
|
||||
|
||||
var userId =
|
||||
context.Principal?.FindFirst("sub")?.Value ??
|
||||
@@ -165,11 +236,61 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
{
|
||||
identity.AddClaim(new Claim("app.provider", "Google"));
|
||||
}
|
||||
|
||||
var refreshToken = context.RefreshToken;
|
||||
if (!string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
var tokenStore = context.HttpContext.RequestServices.GetRequiredService<IRefreshTokenStore>();
|
||||
var userEmail = context.Principal?.FindFirst(ClaimTypes.Email)?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
var existing = tokenStore.GetAll().FirstOrDefault(r =>
|
||||
string.Equals(r.Provider, "Google", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(r.UserId, userId, StringComparison.Ordinal));
|
||||
|
||||
tokenStore.Upsert(new RefreshTokenRecord
|
||||
{
|
||||
Provider = "Google",
|
||||
Tenant = "google.com",
|
||||
UserId = userId,
|
||||
UserEmail = userEmail,
|
||||
RefreshToken = refreshToken,
|
||||
UpdatedUtc = DateTime.UtcNow,
|
||||
Enabled = existing?.Enabled ?? true
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Auto-create database and tables if they don't exist
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
|
||||
// Note: Database.EnsureCreated() does not use migrations,
|
||||
// it simply creates the schema based on the current model.
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
// Ensure UserProfiles table exists
|
||||
context.Database.OpenConnection();
|
||||
try {
|
||||
using var cmd = context.Database.GetDbConnection().CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS ""UserProfiles"" (
|
||||
""UserId"" TEXT PRIMARY KEY,
|
||||
""UserEmail"" TEXT,
|
||||
""PictureBase64"" TEXT,
|
||||
""LastUpdated"" TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
} finally {
|
||||
context.Database.CloseConnection();
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using HotlinePlanner.Data;
|
||||
using HotlinePlanner.Models;
|
||||
|
||||
namespace HotlinePlanner.Services
|
||||
{
|
||||
@@ -9,33 +11,36 @@ namespace HotlinePlanner.Services
|
||||
private readonly ILogger<CalendarWorker> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IRefreshTokenStore _tokenStore;
|
||||
private readonly CalendarWorkerStatus _status;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public CalendarWorker(
|
||||
ILogger<CalendarWorker> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
IRefreshTokenStore tokenStore,
|
||||
CalendarWorkerStatus status)
|
||||
CalendarWorkerStatus status,
|
||||
IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_configuration = configuration;
|
||||
_tokenStore = tokenStore;
|
||||
_status = status;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await LogEvent("BackgroundWorker", "Background worker starting", "Info");
|
||||
|
||||
var enabled = _configuration.GetValue("CalendarWorker:Enabled", true);
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.LogInformation("CalendarWorker disabled.");
|
||||
await LogEvent("BackgroundWorker", "Background worker disabled by configuration", "Warning");
|
||||
return;
|
||||
}
|
||||
|
||||
var intervalMinutes = _configuration.GetValue("CalendarWorker:IntervalMinutes", 120);
|
||||
var intervalMinutes = _configuration.GetValue("CalendarWorker:IntervalMinutes", 60);
|
||||
var delay = TimeSpan.FromMinutes(intervalMinutes);
|
||||
_status.UpdateNext(DateTime.UtcNow.Add(delay));
|
||||
|
||||
@@ -43,13 +48,16 @@ namespace HotlinePlanner.Services
|
||||
{
|
||||
try
|
||||
{
|
||||
await LogEvent("BackgroundWorker", "Starting hourly refresh cycle", "Info");
|
||||
await RunOnce(stoppingToken);
|
||||
_status.UpdateRun(DateTime.UtcNow, "Success");
|
||||
await LogEvent("BackgroundWorker", "Hourly refresh cycle completed successfully", "Info");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CalendarWorker failed.");
|
||||
_status.UpdateRun(DateTime.UtcNow, $"Error: {ex.GetType().Name}");
|
||||
await LogEvent("BackgroundWorker", $"Refresh cycle failed: {ex.Message}", "Error");
|
||||
}
|
||||
|
||||
try
|
||||
@@ -62,18 +70,22 @@ namespace HotlinePlanner.Services
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await LogEvent("BackgroundWorker", "Background worker stopping", "Info");
|
||||
}
|
||||
|
||||
private async Task RunOnce(CancellationToken stoppingToken)
|
||||
{
|
||||
var records = _tokenStore.GetAll()
|
||||
.Where(r => string.Equals(r.Provider, "Microsoft", StringComparison.OrdinalIgnoreCase))
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var tokenStore = scope.ServiceProvider.GetRequiredService<IRefreshTokenStore>();
|
||||
|
||||
var records = tokenStore.GetAll()
|
||||
.Where(r => r.Enabled)
|
||||
.ToList();
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("CalendarWorker: no Microsoft refresh tokens found.");
|
||||
_logger.LogInformation("CalendarWorker: no enabled refresh tokens found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,27 +96,62 @@ namespace HotlinePlanner.Services
|
||||
break;
|
||||
}
|
||||
|
||||
var (accessToken, newRefreshToken) = await RefreshAccessToken(record.RefreshToken, stoppingToken);
|
||||
try
|
||||
{
|
||||
var (accessToken, newRefreshToken) = await RefreshAccessToken(record, stoppingToken);
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
_logger.LogWarning("CalendarWorker: could not refresh access token for {UserId}.", record.UserId);
|
||||
var msg = $"Could not refresh access token for {record.UserId}";
|
||||
_logger.LogWarning("CalendarWorker: {Message}", msg);
|
||||
await LogEvent("BackgroundWorker", msg, "Warning", record.Tenant, record.UserId);
|
||||
continue;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(newRefreshToken) &&
|
||||
!string.Equals(newRefreshToken, record.RefreshToken, StringComparison.Ordinal))
|
||||
|
||||
// Always update even if token is the same, to update LastUpdated timestamp
|
||||
tokenStore.Upsert(record with
|
||||
{
|
||||
_tokenStore.Upsert(record with { RefreshToken = newRefreshToken, UpdatedUtc = DateTime.UtcNow });
|
||||
RefreshToken = !string.IsNullOrWhiteSpace(newRefreshToken) ? newRefreshToken : record.RefreshToken,
|
||||
UpdatedUtc = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newRefreshToken))
|
||||
{
|
||||
await LogEvent("BackgroundWorker", $"Refresh token rotated and stored for user {record.UserId}", "Info", record.Tenant, record.UserId);
|
||||
}
|
||||
|
||||
// For now we still create a calendar event to verify it works
|
||||
var ok = await CreateCalendarEvent(accessToken, stoppingToken);
|
||||
if (!ok)
|
||||
{
|
||||
_logger.LogWarning("CalendarWorker: failed to create event for {UserId}.", record.UserId);
|
||||
var msg = $"Failed to create calendar event for {record.UserId}";
|
||||
_logger.LogWarning("CalendarWorker: {Message}", msg);
|
||||
await LogEvent("BackgroundWorker", msg, "Warning", record.Tenant, record.UserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await LogEvent("BackgroundWorker", $"Successfully refreshed and created event for {record.UserId}", "Info", record.Tenant, record.UserId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = $"Error processing {record.UserId}: {ex.Message}";
|
||||
_logger.LogError(ex, "CalendarWorker error");
|
||||
await LogEvent("BackgroundWorker", msg, "Error", record.Tenant, record.UserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string? AccessToken, string? RefreshToken)> RefreshAccessToken(string refreshToken, CancellationToken stoppingToken)
|
||||
private async Task<(string? AccessToken, string? RefreshToken)> RefreshAccessToken(RefreshTokenRecord record, CancellationToken stoppingToken)
|
||||
{
|
||||
if (string.Equals(record.Provider, "Microsoft", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await RefreshMicrosoftToken(record.RefreshToken, stoppingToken);
|
||||
}
|
||||
// Add Google support here if needed
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private async Task<(string? AccessToken, string? RefreshToken)> RefreshMicrosoftToken(string refreshToken, CancellationToken stoppingToken)
|
||||
{
|
||||
var clientId = _configuration["Authentication:Microsoft:ClientId"];
|
||||
var clientSecret = _configuration["Authentication:Microsoft:ClientSecret"];
|
||||
@@ -162,8 +209,8 @@ namespace HotlinePlanner.Services
|
||||
|
||||
var payload = new
|
||||
{
|
||||
subject = "Hotline Planner event",
|
||||
body = new { contentType = "Text", content = "This event was created by Hotline Planner." },
|
||||
subject = "Hotline Planner Sync (Background)",
|
||||
body = new { contentType = "Text", content = "This event was created by the Hotline Planner background worker." },
|
||||
start = new { dateTime = nowUtc.ToString("o"), timeZone = "UTC" },
|
||||
end = new { dateTime = endUtc.ToString("o"), timeZone = "UTC" }
|
||||
};
|
||||
@@ -172,5 +219,31 @@ namespace HotlinePlanner.Services
|
||||
var response = await client.PostAsync("https://graph.microsoft.com/v1.0/me/events", content, stoppingToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task LogEvent(string category, string message, string level = "Info", string? tenant = null, string? userId = null, string? payload = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
|
||||
|
||||
context.EventLogs.Add(new EventLog
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Level = level,
|
||||
Category = category,
|
||||
Message = message,
|
||||
Tenant = tenant,
|
||||
UserId = userId,
|
||||
Payload = payload
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to log event to database: {Message}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using HotlinePlanner.Data;
|
||||
using HotlinePlanner.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HotlinePlanner.Services
|
||||
{
|
||||
public class DatabaseRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ITokenEncryptionService _encryptionService;
|
||||
private readonly ILogger<DatabaseRefreshTokenStore> _logger;
|
||||
|
||||
public DatabaseRefreshTokenStore(
|
||||
IServiceProvider serviceProvider,
|
||||
ITokenEncryptionService encryptionService,
|
||||
ILogger<DatabaseRefreshTokenStore> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_encryptionService = encryptionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<RefreshTokenRecord> GetAll()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
|
||||
|
||||
return context.Tokens
|
||||
.Select(t => new RefreshTokenRecord
|
||||
{
|
||||
Provider = t.Provider,
|
||||
Tenant = t.Tenant,
|
||||
UserId = t.UserId,
|
||||
UserEmail = t.UserEmail,
|
||||
RefreshToken = _encryptionService.Decrypt(t.EncryptedToken),
|
||||
UpdatedUtc = t.LastUpdated,
|
||||
Enabled = t.Enabled
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void Upsert(RefreshTokenRecord record)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
|
||||
|
||||
var existing = context.Tokens.FirstOrDefault(t =>
|
||||
t.Provider == record.Provider &&
|
||||
t.UserId == record.UserId);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Tenant = record.Tenant;
|
||||
existing.UserEmail = record.UserEmail;
|
||||
existing.EncryptedToken = _encryptionService.Encrypt(record.RefreshToken);
|
||||
existing.LastUpdated = record.UpdatedUtc;
|
||||
existing.Enabled = record.Enabled;
|
||||
context.Tokens.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Tokens.Add(new Token
|
||||
{
|
||||
Provider = record.Provider,
|
||||
Tenant = record.Tenant,
|
||||
UserId = record.UserId,
|
||||
UserEmail = record.UserEmail,
|
||||
EncryptedToken = _encryptionService.Encrypt(record.RefreshToken),
|
||||
LastUpdated = record.UpdatedUtc,
|
||||
Enabled = record.Enabled,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
_logger.LogInformation("Upserted token for user {UserId} (Provider: {Provider}, Tenant: {Tenant})",
|
||||
record.UserId, record.Provider, record.Tenant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace HotlinePlanner.Services
|
||||
{
|
||||
public interface ITokenEncryptionService
|
||||
{
|
||||
string Encrypt(string plainText);
|
||||
string Decrypt(string encryptedText);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ namespace HotlinePlanner.Services
|
||||
public record RefreshTokenRecord
|
||||
{
|
||||
public string Provider { get; init; } = "";
|
||||
public string Tenant { get; init; } = "default";
|
||||
public string UserId { get; init; } = "";
|
||||
public string? UserEmail { get; init; }
|
||||
public string RefreshToken { get; init; } = "";
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
@@ -16,76 +18,5 @@ namespace HotlinePlanner.Services
|
||||
void Upsert(RefreshTokenRecord record);
|
||||
IReadOnlyList<RefreshTokenRecord> GetAll();
|
||||
}
|
||||
|
||||
public class FileRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public FileRefreshTokenStore(IConfiguration configuration)
|
||||
{
|
||||
var dataPath = configuration["CalendarWorker:TokenStorePath"] ?? "App_Data/refresh-tokens.json";
|
||||
_filePath = Path.IsPathRooted(dataPath)
|
||||
? dataPath
|
||||
: Path.Combine(AppContext.BaseDirectory, dataPath);
|
||||
}
|
||||
|
||||
public IReadOnlyList<RefreshTokenRecord> GetAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return LoadAllInternal();
|
||||
}
|
||||
}
|
||||
|
||||
public void Upsert(RefreshTokenRecord record)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var all = LoadAllInternal();
|
||||
var existingIndex = all.FindIndex(r =>
|
||||
string.Equals(r.Provider, record.Provider, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(r.UserId, record.UserId, StringComparison.Ordinal));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
all[existingIndex] = record;
|
||||
}
|
||||
else
|
||||
{
|
||||
all.Add(record);
|
||||
}
|
||||
|
||||
SaveAllInternal(all);
|
||||
}
|
||||
}
|
||||
|
||||
private List<RefreshTokenRecord> LoadAllInternal()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return new List<RefreshTokenRecord>();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_filePath);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new List<RefreshTokenRecord>();
|
||||
}
|
||||
|
||||
var records = JsonSerializer.Deserialize<List<RefreshTokenRecord>>(json);
|
||||
return records ?? new List<RefreshTokenRecord>();
|
||||
}
|
||||
|
||||
private void SaveAllInternal(List<RefreshTokenRecord> records)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace HotlinePlanner.Services
|
||||
{
|
||||
public class TokenEncryptionService : ITokenEncryptionService
|
||||
{
|
||||
private readonly IDataProtector _protector;
|
||||
private const string Purpose = "HotlinePlanner.TokenStore.v1";
|
||||
|
||||
public TokenEncryptionService(IDataProtectionProvider provider)
|
||||
{
|
||||
_protector = provider.CreateProtector(Purpose);
|
||||
}
|
||||
|
||||
public string Encrypt(string plainText)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plainText)) return plainText;
|
||||
return _protector.Protect(plainText);
|
||||
}
|
||||
|
||||
public string Decrypt(string encryptedText)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encryptedText)) return encryptedText;
|
||||
try
|
||||
{
|
||||
return _protector.Unprotect(encryptedText);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// In case of decryption failure (e.g. key change without migration)
|
||||
// we might want to log this or handle it gracefully.
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
@{
|
||||
ViewData["Title"] = "Event Logs";
|
||||
}
|
||||
|
||||
<div class="animate__animated animate__fadeIn">
|
||||
<div class="row items-center q-mb-lg">
|
||||
<div class="col">
|
||||
<h1 class="text-h4 text-weight-bold text-primary q-ma-none">System Audit Logs</h1>
|
||||
<div class="text-subtitle2 text-grey-6 uppercase q-mt-xs" style="letter-spacing: 1px">Internal Activity Tracking</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat round color="primary" icon="refresh" v-on:click="onRequest({ pagination })">
|
||||
<q-tooltip>Refresh Data</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card flat class="clean-card overflow-hidden">
|
||||
<q-table
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
v-model:pagination="pagination"
|
||||
:loading="loading"
|
||||
:filter="filter"
|
||||
binary-state-sort
|
||||
v-on:request="onRequest"
|
||||
flat
|
||||
bordered
|
||||
class="no-shadow"
|
||||
:rows-per-page-options="[10, 20, 50, 100]"
|
||||
>
|
||||
<template v-slot:top-right>
|
||||
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search logs...">
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-level="props">
|
||||
<q-td :props="props">
|
||||
<q-badge :color="getLevelColor(props.value)" rounded>
|
||||
{{ props.value }}
|
||||
</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-timestamp="props">
|
||||
<q-td :props="props">
|
||||
{{ formatDate(props.value) }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.uppercase { text-transform: uppercase; font-size: 0.7rem; }
|
||||
.clean-card { border-radius: 12px; }
|
||||
</style>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
app.mixin({
|
||||
data() {
|
||||
return {
|
||||
filter: '',
|
||||
loading: false,
|
||||
pagination: {
|
||||
sortBy: 'timestamp',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 10,
|
||||
rowsNumber: 0
|
||||
},
|
||||
columns: [
|
||||
{ name: 'timestamp', label: 'Timestamp (UTC)', field: 'timestamp', align: 'left', sortable: true },
|
||||
{ name: 'level', label: 'Level', field: 'level', align: 'center', sortable: true },
|
||||
{ name: 'category', label: 'Category', field: 'category', align: 'left', sortable: true },
|
||||
{ name: 'message', label: 'Message', field: 'message', align: 'left', sortable: false },
|
||||
{ name: 'userId', label: 'User ID', field: 'userId', align: 'left', sortable: true }
|
||||
],
|
||||
rows: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onRequest({
|
||||
pagination: this.pagination,
|
||||
filter: this.filter
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
async onRequest(props) {
|
||||
const { page, rowsPerPage, sortBy, descending } = props.pagination;
|
||||
const filter = props.filter;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const url = `/Account/GetEventLogsData?page=${page}&rowsPerPage=${rowsPerPage}&filter=${filter || ''}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
this.rows = data.items;
|
||||
this.pagination.rowsNumber = data.totalItems;
|
||||
this.pagination.page = page;
|
||||
this.pagination.rowsPerPage = rowsPerPage;
|
||||
this.pagination.sortBy = sortBy;
|
||||
this.pagination.descending = descending;
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
this.$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to load logs',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
getLevelColor(level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'info': return 'blue-6';
|
||||
case 'warning': return 'orange-8';
|
||||
case 'error': return 'negative';
|
||||
default: return 'grey-6';
|
||||
}
|
||||
},
|
||||
formatDate(val) {
|
||||
if (!val) return '—';
|
||||
const date = new Date(val);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
@{
|
||||
ViewData["Title"] = "Token Store";
|
||||
}
|
||||
|
||||
<div class="animate__animated animate__fadeIn">
|
||||
<div class="row items-center q-mb-lg">
|
||||
<div class="col">
|
||||
<h1 class="text-h4 text-weight-bold text-primary q-ma-none">Identity Store</h1>
|
||||
<div class="text-subtitle2 text-grey-6 uppercase q-mt-xs" style="letter-spacing: 1px">Refresh Token Management</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat round color="primary" icon="refresh" v-on:click="onRequest({ pagination })">
|
||||
<q-tooltip>Refresh Data</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card flat class="clean-card overflow-hidden">
|
||||
<q-table
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
v-model:pagination="pagination"
|
||||
:loading="loading"
|
||||
:filter="filter"
|
||||
binary-state-sort
|
||||
v-on:request="onRequest"
|
||||
flat
|
||||
bordered
|
||||
class="no-shadow"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
>
|
||||
<template v-slot:top-right>
|
||||
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search tokens...">
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-provider="props">
|
||||
<q-td :props="props">
|
||||
<div class="row items-center">
|
||||
<q-icon :name="getProviderIcon(props.value)"
|
||||
:color="getProviderColor(props.value)"
|
||||
size="20px"
|
||||
class="q-mr-sm"></q-icon>
|
||||
{{ props.value }}
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-enabled="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<q-icon :name="props.value ? 'check_circle' : 'cancel'"
|
||||
:color="props.value ? 'positive' : 'grey-5'"
|
||||
size="20px"></q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-lastUpdated="props">
|
||||
<q-td :props="props">
|
||||
{{ formatDate(props.value) }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.uppercase { text-transform: uppercase; font-size: 0.7rem; }
|
||||
.clean-card { border-radius: 12px; }
|
||||
</style>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
app.mixin({
|
||||
data() {
|
||||
return {
|
||||
filter: '',
|
||||
loading: false,
|
||||
pagination: {
|
||||
sortBy: 'lastUpdated',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 10,
|
||||
rowsNumber: 0
|
||||
},
|
||||
columns: [
|
||||
{ name: 'userId', label: 'User ID (GUID)', field: 'userId', align: 'left', sortable: true },
|
||||
{ name: 'userEmail', label: 'Email / Account ID', field: 'userEmail', align: 'left', sortable: true },
|
||||
{ name: 'tenant', label: 'Tenant ID / Domain', field: 'tenant', align: 'left', sortable: true },
|
||||
{ name: 'provider', label: 'Provider', field: 'provider', align: 'left', sortable: true },
|
||||
{ name: 'lastUpdated', label: 'Last Updated (UTC)', field: 'lastUpdated', align: 'left', sortable: true },
|
||||
{ name: 'enabled', label: 'Active', field: 'enabled', align: 'center', sortable: true }
|
||||
],
|
||||
rows: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onRequest({
|
||||
pagination: this.pagination,
|
||||
filter: this.filter
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
async onRequest(props) {
|
||||
const { page, rowsPerPage, sortBy, descending } = props.pagination;
|
||||
const filter = props.filter;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const url = `/Account/GetTokensData?page=${page}&rowsPerPage=${rowsPerPage}&filter=${filter || ''}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
this.rows = data.items;
|
||||
this.pagination.rowsNumber = data.totalItems;
|
||||
this.pagination.page = page;
|
||||
this.pagination.rowsPerPage = rowsPerPage;
|
||||
this.pagination.sortBy = sortBy;
|
||||
this.pagination.descending = descending;
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
this.$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to load tokens',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
getProviderIcon(provider) {
|
||||
switch (provider?.toLowerCase()) {
|
||||
case 'microsoft': return 'widgets';
|
||||
case 'google': return 'login';
|
||||
default: return 'help';
|
||||
}
|
||||
},
|
||||
getProviderColor(provider) {
|
||||
switch (provider?.toLowerCase()) {
|
||||
case 'microsoft': return 'blue-7';
|
||||
case 'google': return 'positive';
|
||||
default: return 'grey-7';
|
||||
}
|
||||
},
|
||||
formatDate(val) {
|
||||
if (!val) return '—';
|
||||
const date = new Date(val);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
ViewData["Title"] = "Start Page";
|
||||
var pictureUrl = User.FindFirst("app.picture")?.Value
|
||||
ViewData["Title"] = "Dashboard";
|
||||
var portraitUrl = User.FindFirst("app.picture")?.Value
|
||||
?? User.FindFirst(System.Security.Claims.ClaimTypes.Uri)?.Value;
|
||||
var isSubscribed = ViewData["IsSubscribed"] as bool? ?? false;
|
||||
var lastRun = ViewData["WorkerLastRun"] as string;
|
||||
@@ -8,55 +8,175 @@
|
||||
var lastResult = ViewData["WorkerLastResult"] as string;
|
||||
}
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Welcome to Hotline Planner</h1>
|
||||
<div class="row q-col-gutter-xl justify-center items-center" style="min-height: 70vh">
|
||||
<div class="col-12 col-md-8 col-lg-6">
|
||||
|
||||
@if (User.Identity != null && User.Identity.IsAuthenticated)
|
||||
<!-- HEADER -->
|
||||
<div class="text-center q-mb-xl">
|
||||
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">NextGen Dashboard</h1>
|
||||
<p class="text-subtitle1 text-grey-7">Real-time enterprise calendar synchronization</p>
|
||||
</div>
|
||||
|
||||
<!-- AUTHENTICATED VIEW -->
|
||||
<div v-if="isAuthenticated" class="animate__animated animate__fadeIn">
|
||||
<q-card flat class="clean-card q-mb-lg overflow-hidden">
|
||||
<q-card-section class="q-pa-xl bg-grey-1 text-center border-bottom">
|
||||
@if (!string.IsNullOrEmpty(portraitUrl))
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(pictureUrl))
|
||||
{
|
||||
<img src="@pictureUrl" alt="Profile Picture" style="width: 80px; height: 80px; border-radius: 50%; margin-bottom: 15px;" />
|
||||
<q-avatar size="100px" class="q-mb-md shadow-1 border-white">
|
||||
<img src="@portraitUrl">
|
||||
</q-avatar>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Placeholder for users without a profile picture (e.g., from Microsoft login) -->
|
||||
<div style="width: 80px; height: 80px; border-radius: 50%; background-color: #ddd; display: inline-block; margin-bottom: 15px;"></div>
|
||||
<q-avatar size="100px" font-size="48px" color="primary" text-color="white" icon="person" class="q-mb-md shadow-1"></q-avatar>
|
||||
}
|
||||
<div class="text-h5 text-weight-bold text-grey-9">{{userName}}</div>
|
||||
<div class="text-caption text-grey-6 text-uppercase q-mt-xs" style="letter-spacing: 1px">Active Session</div>
|
||||
<q-btn flat dense rounded color="grey-6" icon="refresh" label="Update Photo" size="sm" class="q-mt-md" v-on:click="refreshPhoto"></q-btn>
|
||||
</q-card-section>
|
||||
|
||||
<h4 class="lead">Hello, @User.Identity.Name!</h4>
|
||||
<p>You have successfully logged in.</p>
|
||||
<div class="mb-3">
|
||||
<form asp-controller="Account" asp-action="ToggleSubscription" method="post" style="display:inline-block;">
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
@(isSubscribed ? "Deactivate Calendar" : "Activate Calendar")
|
||||
</button>
|
||||
</form>
|
||||
<span class="ms-2">Status: <strong>@(isSubscribed ? "Active" : "Inactive")</strong></span>
|
||||
<q-card-section class="q-pa-lg">
|
||||
<div class="row items-center justify-between q-mb-lg">
|
||||
<div class="text-subtitle1 text-weight-bold">Engine Status</div>
|
||||
<q-chip :color="isSubscribed ? 'positive' : 'grey-4'"
|
||||
:text-color="isSubscribed ? 'white' : 'grey-9'"
|
||||
:icon="isSubscribed ? 'check_circle' : 'pause_circle'"
|
||||
class="q-px-md">
|
||||
{{ isSubscribed ? 'Running' : 'Paused' }}
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div>Last run: <strong>@(lastRun ?? "n/a")</strong></div>
|
||||
<div>Next run: <strong>@(nextRun ?? "n/a")</strong></div>
|
||||
<div>Last result: <strong>@(lastResult ?? "n/a")</strong></div>
|
||||
|
||||
<div class="bg-grey-1 q-pa-md rounded-borders border">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-4 text-center border-right">
|
||||
<div class="text-caption text-grey-6 uppercase">Last Sync</div>
|
||||
<div class="text-weight-bold text-grey-9">{{ lastRun || 'Waiting...' }}</div>
|
||||
</div>
|
||||
<div class="col-4 text-center border-right">
|
||||
<div class="text-caption text-grey-6 uppercase">Next Run</div>
|
||||
<div class="text-weight-bold text-grey-9 text-primary">{{ nextRun || 'Scheduled' }}</div>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<div class="text-caption text-grey-6 uppercase">Health</div>
|
||||
<div>
|
||||
<q-badge :color="lastResult === 'Success' ? 'positive' : 'negative'" v-if="lastResult" rounded>
|
||||
{{ lastResult }}
|
||||
</q-badge>
|
||||
<span v-else class="text-grey-4">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="center" class="q-pb-xl q-px-xl">
|
||||
<q-btn :label="isSubscribed ? 'Stop Sync Engine' : 'Start Sync Engine'"
|
||||
:color="isSubscribed ? 'negative' : 'primary'"
|
||||
:icon="isSubscribed ? 'stop' : 'play_arrow'"
|
||||
unelevated
|
||||
rounded
|
||||
size="lg"
|
||||
class="full-width q-py-md text-weight-bold"
|
||||
v-on:click="toggleSubscription"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-sm-4">
|
||||
<q-btn outline color="secondary" icon="update" label="Manual Sync" class="full-width q-py-md rounded-borders" v-on:click="createEvent"></q-btn>
|
||||
</div>
|
||||
<div class="col-12 col-sm-4">
|
||||
<q-btn outline color="grey-7" icon="event" label="View Logs" class="full-width q-py-md rounded-borders" href="/Account/EventLogs"></q-btn>
|
||||
</div>
|
||||
<div class="col-12 col-sm-4">
|
||||
<q-btn flat color="grey-6" icon="logout" label="End Session" class="full-width q-py-md rounded-borders" v-on:click="logout"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGIN VIEW -->
|
||||
<div v-else class="animate__animated animate__fadeIn">
|
||||
<q-card flat class="clean-card q-pa-xl text-center">
|
||||
<q-avatar size="80px" color="blue-1" text-color="primary" icon="security" class="q-mb-lg"></q-avatar>
|
||||
<div class="text-h5 text-weight-bold q-mb-sm text-grey-9">Identity Portal</div>
|
||||
<p class="text-body1 text-grey-7 q-mb-xl">Please sign in with your enterprise credentials to access the NextGen synchronization platform.</p>
|
||||
|
||||
<div class="column q-gutter-md">
|
||||
<q-btn unelevated color="primary" size="lg" rounded v-on:click="login('Microsoft')" class="full-width q-py-md shadow-1">
|
||||
<q-icon name="widgets" class="q-mr-sm"></q-icon>
|
||||
Connect via Microsoft 365
|
||||
</q-btn>
|
||||
|
||||
<q-btn outline color="grey-7" size="lg" rounded v-on:click="login('Google')" class="full-width q-py-md">
|
||||
<q-icon name="login" class="q-mr-sm" color="negative"></q-icon>
|
||||
Connect via Google Workspace
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<div class="q-mt-xl text-caption text-grey-5 flex flex-center items-center">
|
||||
<q-icon name="lock" class="q-mr-xs"></q-icon> Protected by secure RSA 4096-bit encryption
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<form asp-controller="Account" asp-action="CreateCalendarEvent" method="post">
|
||||
<button type="submit" class="btn btn-outline-primary">Add Calendar Event</button>
|
||||
</form>
|
||||
<form asp-controller="Account" asp-action="Logout" method="post">
|
||||
<button type="submit" class="btn btn-outline-danger">Log Out</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="lead">Please sign in to continue.</p>
|
||||
<div class="d-grid gap-2 col-md-4 mx-auto">
|
||||
<form asp-controller="Account" asp-action="ExternalLogin" method="post">
|
||||
<input type="hidden" name="provider" value="Microsoft" />
|
||||
<button type="submit" class="btn btn-lg btn-primary w-100">Login with Microsoft</button>
|
||||
</form>
|
||||
<form asp-controller="Account" asp-action="ExternalLogin" method="post">
|
||||
<input type="hidden" name="provider" value="Google" />
|
||||
<button type="submit" class="btn btn-lg btn-success w-100">Login with Google</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.border-bottom { border-bottom: 1px solid rgba(0,0,0,0.05); }
|
||||
.border-right { border-right: 1px solid rgba(0,0,0,0.05); }
|
||||
.border { border: 1px solid rgba(0,0,0,0.05); }
|
||||
.uppercase { text-transform: uppercase; font-size: 0.65rem; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
</style>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
app.mixin({
|
||||
data() {
|
||||
return {
|
||||
isSubscribed: @(isSubscribed.ToString().ToLower()),
|
||||
lastRun: '@lastRun',
|
||||
nextRun: '@nextRun',
|
||||
lastResult: '@lastResult'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login(provider) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/Account/ExternalLogin';
|
||||
|
||||
const providerInput = document.createElement('input');
|
||||
providerInput.type = 'hidden';
|
||||
providerInput.name = 'provider';
|
||||
providerInput.value = provider;
|
||||
|
||||
form.appendChild(providerInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
toggleSubscription() {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/Account/ToggleSubscription';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
createEvent() {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/Account/CreateCalendarEvent';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
refreshPhoto() {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/Account/SyncProfilePicture';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -3,48 +3,222 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - HotlinePlanner</title>
|
||||
<script type="importmap"></script>
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/HotlinePlanner.styles.css" asp-append-version="true" />
|
||||
<title>@ViewData["Title"] - Hotline Planner</title>
|
||||
|
||||
<!-- Premium Typography & Icons -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Material+Icons" rel="stylesheet" type="text/css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/animate.css@4.0.0/animate.min.css" rel="stylesheet" type="text/css">
|
||||
|
||||
<!-- Quasar CSS (Stable CDN) -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/quasar@2.14.2/dist/quasar.prod.css" rel="stylesheet" type="text/css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--brand-primary: #1976D2;
|
||||
--brand-secondary: #26A69A;
|
||||
--bg-light: #F8F9FA;
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: var(--bg-light);
|
||||
color: #1D1D1D;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clean-header {
|
||||
background: white !important;
|
||||
color: #1D1D1D !important;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.clean-card {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
[v-cloak] { display: none !important; }
|
||||
|
||||
.q-toolbar__title {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.q-btn {
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-active {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(25, 118, 210, 0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.border-top { border-top: 1px solid var(--border-color); }
|
||||
|
||||
/* Page scale transition */
|
||||
.page-fade-enter-active, .page-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.page-fade-enter-from { opacity: 0; transform: scale(0.99); }
|
||||
.page-fade-leave-to { opacity: 0; transform: scale(1.01); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">HotlinePlanner</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||
<ul class="navbar-nav flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div id="q-app" v-cloak>
|
||||
<q-layout view="lHh Lpr lFf">
|
||||
<q-header elevated class="clean-header">
|
||||
<q-toolbar class="q-px-lg">
|
||||
<q-btn flat round dense icon="menu" color="primary" v-on:click="leftDrawerOpen = !leftDrawerOpen"></q-btn>
|
||||
<q-toolbar-title class="text-primary">
|
||||
Hotline Planner
|
||||
</q-toolbar-title>
|
||||
<div class="gt-xs text-grey-6 q-mr-lg">NextGen v{{appVersion}}</div>
|
||||
|
||||
<div v-if="isAuthenticated">
|
||||
<q-btn flat no-caps color="primary" class="q-px-md">
|
||||
<q-avatar size="32px" class="q-mr-sm">
|
||||
<q-icon name="account_circle" size="32px"></q-icon>
|
||||
</q-avatar>
|
||||
<span class="gt-sm">{{userName}}</span>
|
||||
<q-menu transition-show="jump-down" transition-hide="jump-up" class="clean-card shadow-12">
|
||||
<q-list style="min-width: 200px">
|
||||
<q-item class="q-py-md">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="person" color="grey-2" text-color="primary"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-bold">{{userName}}</q-item-label>
|
||||
<q-item-label caption>Administrator</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item clickable v-v-close-popup v-on:click="logout">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" color="negative"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section class="text-negative">Sign Out</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
<main role="main" class="pb-3">
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<q-drawer v-model="leftDrawerOpen" bordered class="bg-white" :width="260">
|
||||
<q-scroll-area class="fit">
|
||||
<q-list padding class="q-mt-md">
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-grey-6 q-px-lg" style="font-size: 0.7rem; letter-spacing: 1px;">Navigation</q-item-label>
|
||||
|
||||
<q-item clickable tag="a" href="/" class="q-mx-md q-mb-xs rounded-borders" active-class="nav-active" :active="isCurrentPath('/')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="dashboard"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>Dashboard</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-grey-6 q-px-lg q-mt-md" style="font-size: 0.7rem; letter-spacing: 1px;">Auditing</q-item-label>
|
||||
|
||||
<q-item clickable tag="a" href="/Account/EventLogs" class="q-mx-md q-mb-xs rounded-borders" active-class="nav-active" :active="isCurrentPath('/Account/EventLogs')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="assignment"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>Event Logs</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable tag="a" href="/Account/Tokens" class="q-mx-md q-mb-xs rounded-borders" active-class="nav-active" :active="isCurrentPath('/Account/Tokens')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="vpn_key"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>Token Store</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item-label header class="text-weight-bold text-uppercase text-grey-6 q-px-lg q-mt-md" style="font-size: 0.7rem; letter-spacing: 1px;">System</q-item-label>
|
||||
|
||||
<q-item clickable tag="a" href="/Home/Privacy" class="q-mx-md rounded-borders" active-class="nav-active" :active="isCurrentPath('/Home/Privacy')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="security"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>Privacy Policy</q-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<q-page class="q-pa-xl">
|
||||
<div class="max-width-1200 q-mx-auto">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
|
||||
<q-footer class="bg-white text-grey-6 q-pa-md text-center border-top">
|
||||
<div class="text-caption">
|
||||
© @DateTime.Now.Year - Hotline Planner NextGen - Secure Workforce Intelligence
|
||||
</div>
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
</div>
|
||||
|
||||
<footer class="border-top footer text-muted">
|
||||
<div class="container">
|
||||
© 2026 - HotlinePlanner - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
<!-- Vue 3 & Quasar (Stable CDNs) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/quasar@2.14.2/dist/quasar.umd.prod.js"></script>
|
||||
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
leftDrawerOpen: false,
|
||||
appVersion: '2.0.0',
|
||||
isAuthenticated: @(User.Identity?.IsAuthenticated.ToString().ToLower() ?? "false"),
|
||||
userName: '@(User.Identity?.Name ?? "")'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout() {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/Account/Logout';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
isCurrentPath(path) {
|
||||
return window.location.pathname === path;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use(Quasar, {
|
||||
config: {
|
||||
brand: {
|
||||
primary: '#1976D2',
|
||||
secondary: '#26A69A',
|
||||
accent: '#9C27B0',
|
||||
dark: '#1D1D1D',
|
||||
positive: '#2E7D32',
|
||||
negative: '#D32F2F',
|
||||
info: '#0288D1',
|
||||
warning: '#F57C00'
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
|
||||
<script>
|
||||
if (!window.vueAppMounted) {
|
||||
app.mount('#q-app');
|
||||
window.vueAppMounted = true;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"CalendarWorker": {
|
||||
"Enabled": true,
|
||||
"IntervalMinutes": 60,
|
||||
"IntervalMinutes": 5,
|
||||
"TokenStorePath": "App_Data/refresh-tokens.json",
|
||||
"TokenScope": "User.Read Calendars.ReadWrite offline_access"
|
||||
},
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=hotline_planner_db;Username=hotline_planner;Password=iE8Jcx7EwhcMZ2cO"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net10.0",
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "10.0.0"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft.AspNetCore.App",
|
||||
"version": "10.0.0"
|
||||
}
|
||||
],
|
||||
"configProperties": {
|
||||
"System.GC.Server": true,
|
||||
"System.Reflection.NullabilityInfoContext.IsSupported": true,
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"ContentRoots":["/Users/dn/Documents/consulity/code/Hotline Planner NextGen 2027/code/dev/backend/HotlinePlanner profile pic and bg worker/wwwroot/","/Users/dn/Documents/consulity/code/Hotline Planner NextGen 2027/code/dev/backend/HotlinePlanner profile pic and bg worker/obj/Debug/net10.0/compressed/","/Users/dn/Documents/consulity/code/Hotline Planner NextGen 2027/code/dev/backend/HotlinePlanner profile pic and bg worker/obj/Debug/net10.0/scopedcss/bundle/"],"Root":{"Children":{"favicon.ico":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"favicon.ico"},"Patterns":null},"favicon.ico.gz":{"Children":null,"Asset":{"ContentRootIndex":1,"SubPath":"pl06rac876-{0}-61n19gt1b8-61n19gt1b8.gz"},"Patterns":null},"HotlinePlanner.styles.css":{"Children":null,"Asset":{"ContentRootIndex":2,"SubPath":"HotlinePlanner.styles.css"},"Patterns":null},"HotlinePlanner.styles.css.gz":{"Children":null,"Asset":{"ContentRootIndex":1,"SubPath":"2eeeskz00d-{0}-s181cdok3c-s181cdok3c.gz"},"Patterns":null},"css":{"Children":{"site.css":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"css/site.css"},"Patterns":null},"site.css.gz":{"Children":null,"Asset":{"ContentRootIndex":1,"SubPath":"1xx720lhjo-{0}-b9sayid5wm-b9sayid5wm.gz"},"Patterns":null}},"Asset":null,"Patterns":null},"js":{"Children":{"site.js":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"js/site.js"},"Patterns":null},"site.js.gz":{"Children":null,"Asset":{"ContentRootIndex":1,"SubPath":"gsi51lty16-{0}-xtxxf3hu2r-xtxxf3hu2r.gz"},"Patterns":null}},"Asset":null,"Patterns":null}},"Asset":null,"Patterns":[{"ContentRootIndex":0,"Pattern":"**","Depth":0}]}}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"Authentication": {
|
||||
"Microsoft": {
|
||||
"ClientId": "9d4fb416-c22c-4a83-be54-c64b32390512",
|
||||
"ClientSecret": "0SK8Q~mVY7WxGY96qh8Z-a_ZPG_37XNgc6DMKcaG",
|
||||
"TenantId": "organizations"
|
||||
},
|
||||
"Google": {
|
||||
"ClientId": "591804741074-sj9jdr4gp3nj3ftrs91s9cila0v2o3mi.apps.googleusercontent.com",
|
||||
"ClientSecret": "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"
|
||||
}
|
||||
},
|
||||
"CalendarWorker": {
|
||||
"Enabled": true,
|
||||
"IntervalMinutes": 5,
|
||||
"TokenStorePath": "App_Data/refresh-tokens.json",
|
||||
"TokenScope": "User.Read Calendars.ReadWrite offline_access"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=hotline_planner_db;Username=hotline_planner;Password=iE8Jcx7EwhcMZ2cO"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user