142 lines
5.1 KiB
C#
142 lines
5.1 KiB
C#
using System.Security.Claims;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace JobTrackerApi.Controllers;
|
|
|
|
/// <summary>
|
|
/// Outlook / Microsoft 365 connection lifecycle (connect, OAuth callback, status, disconnect).
|
|
/// Mirrors the Gmail OAuth surface in <see cref="GmailController"/>. Message search/import runs
|
|
/// through the provider-neutral <c>IEmailProvider</c> seam once a job's correspondence flow is
|
|
/// generalised past Gmail; this controller only owns the Microsoft-specific connection lifecycle.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/microsoft-graph")]
|
|
[Authorize]
|
|
public sealed class MicrosoftGraphController : ControllerBase
|
|
{
|
|
private readonly IMicrosoftGraphOAuthService _graph;
|
|
private readonly ExternalOrigin _externalOrigin;
|
|
|
|
public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg, ExternalOrigin? externalOrigin = null)
|
|
{
|
|
_graph = graph;
|
|
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
|
}
|
|
|
|
public sealed record MicrosoftGraphConnectionStatusDto(
|
|
bool Connected,
|
|
string? MailAddress,
|
|
DateTimeOffset? ConnectedAt,
|
|
DateTimeOffset? LastSyncedAt,
|
|
DateTimeOffset? LastSyncAttemptedAt,
|
|
DateTimeOffset? LastSyncSucceededAt,
|
|
string? LastSyncMode,
|
|
string? LastSyncSource,
|
|
string? LastSyncStatus,
|
|
string? LastSyncError);
|
|
|
|
[HttpGet("status")]
|
|
public async Task<ActionResult<MicrosoftGraphConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetRequiredOwnerUserId();
|
|
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
|
|
return Ok(new MicrosoftGraphConnectionStatusDto(
|
|
connection is not null,
|
|
connection?.MailAddress,
|
|
connection?.ConnectedAt,
|
|
connection?.LastSyncedAt,
|
|
connection?.LastSyncAttemptedAt,
|
|
connection?.LastSyncSucceededAt,
|
|
connection?.LastSyncMode,
|
|
connection?.LastSyncSource,
|
|
connection?.LastSyncStatus,
|
|
connection?.LastSyncError));
|
|
}
|
|
|
|
[HttpGet("connect-url")]
|
|
public IActionResult ConnectUrl()
|
|
{
|
|
var ownerUserId = GetRequiredOwnerUserId();
|
|
var url = _graph.BuildAuthorizationUrl(ownerUserId, GetRedirectUri());
|
|
return Ok(new { url });
|
|
}
|
|
|
|
[AllowAnonymous]
|
|
[HttpGet("oauth/callback")]
|
|
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(error))
|
|
{
|
|
return Content(BuildPopupHtml(false, $"Microsoft returned an error: {error}"), "text/html");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
|
|
{
|
|
return Content(BuildPopupHtml(false, "Missing Microsoft OAuth code or state."), "text/html");
|
|
}
|
|
|
|
var ownerUserId = _graph.ConsumeState(state);
|
|
if (string.IsNullOrWhiteSpace(ownerUserId))
|
|
{
|
|
return Content(BuildPopupHtml(false, "This Outlook connection request is no longer valid. Start the connection again."), "text/html");
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await _graph.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken);
|
|
return Content(BuildPopupHtml(true, $"Connected Outlook: {result.MailAddress}"), "text/html");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Content(BuildPopupHtml(false, ex.Message), "text/html");
|
|
}
|
|
}
|
|
|
|
[HttpDelete("connection")]
|
|
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetRequiredOwnerUserId();
|
|
await _graph.DisconnectAsync(ownerUserId, cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
private string GetRequiredOwnerUserId()
|
|
{
|
|
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
|
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
|
}
|
|
|
|
private string GetRedirectUri()
|
|
{
|
|
return _externalOrigin.BuildPath("/api/microsoft-graph/oauth/callback");
|
|
}
|
|
|
|
private static string BuildPopupHtml(bool success, string message)
|
|
{
|
|
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
|
var status = success ? "connected" : "error";
|
|
var title = success ? "Outlook connected" : "Outlook connection failed";
|
|
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
|
|
return $@"<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset=""utf-8"" />
|
|
<title>Outlook connection</title>
|
|
</head>
|
|
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
|
|
<h2>{title}</h2>
|
|
<p>{escaped}</p>
|
|
<p>You can close this window.</p>
|
|
<script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{ source: 'jobtracker-microsoft-oauth', status: '{status}', message: {serializedMessage} }}, '*');
|
|
}}
|
|
window.close();
|
|
</script>
|
|
</body>
|
|
</html>";
|
|
}
|
|
}
|