Added new modules and updated existing logic

This commit is contained in:
Dieter Neumann
2026-02-24 13:32:01 +01:00
parent 2a4b4ed5fe
commit ad734273ce
694 changed files with 27935 additions and 610 deletions

View File

@@ -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">

View File

@@ -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");
var properties = new AuthenticationProperties
{
RedirectUri = Url.Action("Index", "Home")
};
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}");
}
_cache.Remove($"at:Microsoft:{userId}");
_cache.Remove($"at:Google:{userId}");
}
if (!string.IsNullOrWhiteSpace(userId))
private async Task LogEvent(string category, string message, string level = "Info", string? tenant = null, string? userId = null)
{
try
{
_cache.Remove($"at:Microsoft:{userId}");
_cache.Remove($"at:Google:{userId}");
_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 */ }
}
}
}

View File

@@ -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();
});
}
}
}

View File

@@ -1,16 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<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>
</Project>

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -5,20 +5,33 @@ using Microsoft.Extensions.Caching.Memory;
using System.Net.Http.Headers;
using System.Security.Claims;
using HotlinePlanner.Services;
using System.Linq;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
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>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
@@ -93,37 +106,94 @@ 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;
}
};
})
.AddGoogle(googleOptions =>
{
var googleAuthNSection = builder.Configuration.GetSection("Authentication:Google");
googleOptions.ClientId = googleAuthNSection["ClientId"]
?? throw new InvalidOperationException("Google ClientId is not configured.");
{
var googleAuthNSection = builder.Configuration.GetSection("Authentication:Google");
googleOptions.ClientId = googleAuthNSection["ClientId"]
?? throw new InvalidOperationException("Google ClientId is not configured.");
googleOptions.ClientSecret = googleAuthNSection["ClientSecret"]
?? throw new InvalidOperationException("Google ClientSecret is not configured.");
@@ -137,14 +207,15 @@ 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); }
// Remove the old 'picture' claim if it exists, to avoid conflicts.
var oldPictureClaim = claimsIdentity.FindFirst(ClaimTypes.Uri);
if (oldPictureClaim != null) { claimsIdentity.RemoveClaim(oldPictureClaim); }
// Store the URL instead of base64 to keep cookies small.
claimsIdentity.AddClaim(new Claim("app.picture", pictureUrl));
// Store the URL instead of base64 to keep cookies small.
claimsIdentity.AddClaim(new Claim("app.picture", pictureUrl));
}
}
var userId =
@@ -165,25 +236,75 @@ 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();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
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())
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();

View File

@@ -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);
if (string.IsNullOrWhiteSpace(accessToken))
try
{
_logger.LogWarning("CalendarWorker: could not refresh access token for {UserId}.", record.UserId);
continue;
}
if (!string.IsNullOrWhiteSpace(newRefreshToken) &&
!string.Equals(newRefreshToken, record.RefreshToken, StringComparison.Ordinal))
{
_tokenStore.Upsert(record with { RefreshToken = newRefreshToken, UpdatedUtc = DateTime.UtcNow });
}
var (accessToken, newRefreshToken) = await RefreshAccessToken(record, stoppingToken);
if (string.IsNullOrWhiteSpace(accessToken))
{
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;
}
var ok = await CreateCalendarEvent(accessToken, stoppingToken);
if (!ok)
// Always update even if token is the same, to update LastUpdated timestamp
tokenStore.Upsert(record with
{
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)
{
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)
{
_logger.LogWarning("CalendarWorker: failed to create event for {UserId}.", record.UserId);
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);
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,8 @@
namespace HotlinePlanner.Services
{
public interface ITokenEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string encryptedText);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -1,62 +1,182 @@
@{
ViewData["Title"] = "Start Page";
var pictureUrl = 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;
var nextRun = ViewData["WorkerNextRun"] as string;
var lastResult = ViewData["WorkerLastResult"] as string;
}
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;
var nextRun = ViewData["WorkerNextRun"] as string;
var lastResult = ViewData["WorkerLastResult"] as string;
}
<div class="text-center">
<h1 class="display-4">Welcome to Hotline Planner</h1>
@if (User.Identity != null && User.Identity.IsAuthenticated)
{
@if (!string.IsNullOrEmpty(pictureUrl))
{
<img src="@pictureUrl" alt="Profile Picture" style="width: 80px; height: 80px; border-radius: 50%; margin-bottom: 15px;" />
}
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>
}
<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>
</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>
<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 class="row q-col-gutter-xl justify-center items-center" style="min-height: 70vh">
<div class="col-12 col-md-8 col-lg-6">
<!-- 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))
{
<q-avatar size="100px" class="q-mb-md shadow-1 border-white">
<img src="@portraitUrl">
</q-avatar>
}
else
{
<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>
<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="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>
</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>
}

View File

@@ -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>
</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()
</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">
&copy; @DateTime.Now.Year - Hotline Planner NextGen - Secure Workforce Intelligence
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</q-footer>
</q-layout>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 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>

View File

@@ -12,7 +12,7 @@
},
"CalendarWorker": {
"Enabled": true,
"IntervalMinutes": 60,
"IntervalMinutes": 5,
"TokenStorePath": "App_Data/refresh-tokens.json",
"TokenScope": "User.Read Calendars.ReadWrite offline_access"
},
@@ -22,4 +22,4 @@
"Microsoft.AspNetCore": "Warning"
}
}
}
}

View File

@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=hotline_planner_db;Username=hotline_planner;Password=iE8Jcx7EwhcMZ2cO"
},
"AllowedHosts": "*"
}
}

View File

@@ -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
}
}
}

View File

@@ -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}]}}

View File

@@ -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"
}
}
}

View File

@@ -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": "*"
}

Some files were not shown because too many files have changed in this diff Show More