Dashboard now gets profile
Merge coming soon...
This commit is contained in:
parent
c5e99d9d8d
commit
404e314f97
@ -3,5 +3,9 @@
|
||||
<PropertyGroup>
|
||||
<RazorPage_SelectedScaffolderID>RazorPageScaffolder</RazorPage_SelectedScaffolderID>
|
||||
<RazorPage_SelectedScaffolderCategoryPath>root/Common/RazorPage</RazorPage_SelectedScaffolderCategoryPath>
|
||||
<ActiveDebugProfile>JobsJobsJobs</ActiveDebugProfile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
||||
</PropertyGroup>
|
||||
</Project>
|
@ -1,3 +1,36 @@
|
||||
@page "/citizen/authorized"
|
||||
@inject HttpClient http
|
||||
@inject NavigationManager nav
|
||||
@inject AppState state
|
||||
|
||||
<p>@Message</p>
|
||||
<p>@message</p>
|
||||
|
||||
@code {
|
||||
string message = "Logging you on with No Agenda Social...";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Exchange authorization code for a JWT
|
||||
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
|
||||
if (query.TryGetValue("code", out var authCode))
|
||||
{
|
||||
var logOnResult = await ServerApi.LogOn(http, authCode);
|
||||
|
||||
if (logOnResult.IsOk)
|
||||
{
|
||||
var logOn = logOnResult.Ok;
|
||||
state.User = new UserInfo(logOn.CitizenId, logOn.Name);
|
||||
state.Jwt = logOn.Jwt;
|
||||
nav.NavigateTo("/citizen/dashboard");
|
||||
}
|
||||
else
|
||||
{
|
||||
message = logOnResult.Error;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,69 +0,0 @@
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Client.Pages.Citizen
|
||||
{
|
||||
public partial class Authorized : ComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The message to be displayed to the user
|
||||
/// </summary>
|
||||
public string Message { get; set; } = "Logging you on with No Agenda Social...";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for performing API calls
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public HttpClient Http { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation manager for getting parameters from the URL
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public NavigationManager Navigation { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Application state
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public AppState State { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Exchange authorization code for a JWT
|
||||
var query = QueryHelpers.ParseQuery(Navigation.ToAbsoluteUri(Navigation.Uri).Query);
|
||||
if (query.TryGetValue("code", out var authCode))
|
||||
{
|
||||
var logOnResult = await Http.GetAsync($"api/citizen/log-on/{authCode}");
|
||||
|
||||
if (logOnResult != null)
|
||||
{
|
||||
if (logOnResult.IsSuccessStatusCode)
|
||||
{
|
||||
var logOn = (await logOnResult.Content.ReadFromJsonAsync<LogOnSuccess>())!;
|
||||
State.User = new UserInfo(logOn.CitizenId, logOn.Name);
|
||||
State.Jwt = logOn.Jwt;
|
||||
Navigation.NavigateTo("/citizen/dashboard");
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorMessage = await logOnResult.Content.ReadAsStringAsync();
|
||||
Message = $"Unable to log on with No Agenda Social: {errorMessage}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Message = "Unable to log on with No Agenda Social. This should never happen; contact @danieljsummers";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +1,38 @@
|
||||
@page "/citizen/dashboard"
|
||||
@inject HttpClient http
|
||||
@inject AppState state
|
||||
|
||||
<h3>Dashboard</h3>
|
||||
|
||||
<p>Here's the dashboard, homie...</p>
|
||||
|
||||
@if (hasProfile != null)
|
||||
{
|
||||
<p>Has profile? @hasProfile</p>
|
||||
}
|
||||
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<p>@errorMessage</p>
|
||||
}
|
||||
@code {
|
||||
|
||||
bool? hasProfile = null;
|
||||
string? errorMessage = null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (state.User != null)
|
||||
{
|
||||
var profile = await ServerApi.RetrieveProfile(http, state);
|
||||
if (profile.IsOk)
|
||||
{
|
||||
hasProfile = profile.Ok != null;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = profile.Error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
81
src/JobsJobsJobs/Client/ServerApi.cs
Normal file
81
src/JobsJobsJobs/Client/ServerApi.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Functions used to access the API
|
||||
/// </summary>
|
||||
public static class ServerApi
|
||||
{
|
||||
/// <summary>
|
||||
/// Create an API URL
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to append to the API base URL</param>
|
||||
/// <returns>The full URL to be used in HTTP requests</returns>
|
||||
private static string ApiUrl(string url) => $"/api/{url}";
|
||||
|
||||
/// <summary>
|
||||
/// Create an HTTP request with an authorization header
|
||||
/// </summary>
|
||||
/// <param name="state">The current application state</param>
|
||||
/// <param name="url">The URL for the request (will be appended to the API root)</param>
|
||||
/// <param name="method">The request method (optional, defaults to GET)</param>
|
||||
/// <returns>A request with the header attached, ready for further manipulation</returns>
|
||||
private static HttpRequestMessage WithHeader(AppState state, string url, HttpMethod? method = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method ?? HttpMethod.Get, ApiUrl(url));
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt);
|
||||
return req;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log on a user with the authorization code received from No Agenda Social
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client to use for server communication</param>
|
||||
/// <param name="authCode">The authorization code received from NAS</param>
|
||||
/// <returns>The log on details if successful, an error if not</returns>
|
||||
public static async Task<Result<LogOnSuccess>> LogOn(HttpClient http, string authCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logOn = await http.GetFromJsonAsync<LogOnSuccess>(ApiUrl($"citizen/log-on/{authCode}"));
|
||||
if (logOn == null) {
|
||||
return Result<LogOnSuccess>.AsError(
|
||||
"Unable to log on with No Agenda Social. This should never happen; contact @danieljsummers");
|
||||
}
|
||||
return Result<LogOnSuccess>.AsOk(logOn);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return Result<LogOnSuccess>.AsError($"Unable to log on with No Agenda Social: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a citizen's profile
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client to use for server communication</param>
|
||||
/// <param name="state">The current application state</param>
|
||||
/// <returns>The citizen's profile, null if it is not found, or an error message if one occurs</returns>
|
||||
public static async Task<Result<Profile?>> RetrieveProfile(HttpClient http, AppState state)
|
||||
{
|
||||
var req = WithHeader(state, "profile/");
|
||||
var res = await http.SendAsync(req);
|
||||
return true switch
|
||||
{
|
||||
_ when res.StatusCode == HttpStatusCode.NoContent => Result<Profile?>.AsOk(null),
|
||||
_ when res.IsSuccessStatusCode => Result<Profile?>.AsOk(await res.Content.ReadFromJsonAsync<Profile>()),
|
||||
_ => Result<Profile?>.AsError(await res.Content.ReadAsStringAsync()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -9,3 +9,4 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using JobsJobsJobs.Client
|
||||
@using JobsJobsJobs.Client.Shared
|
||||
@using JobsJobsJobs.Shared
|
||||
|
@ -1,8 +1,14 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
@ -11,10 +17,24 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
[ApiController]
|
||||
public class ProfileController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
/// <summary>
|
||||
/// The database connection
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection db;
|
||||
|
||||
public ProfileController(NpgsqlConnection dbConn)
|
||||
{
|
||||
db = dbConn;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
return null;
|
||||
await db.OpenAsync();
|
||||
var profile = await db.FindProfileByCitizen(
|
||||
CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value));
|
||||
return profile == null ? NoContent() : Ok(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,11 +13,11 @@ namespace JobsJobsJobs.Server.Data
|
||||
public static class NpgsqlDataReaderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a string by its name
|
||||
/// Get a boolean by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a string</param>
|
||||
/// <returns>The specified field as a string</returns>
|
||||
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
|
||||
/// <param name="name">The name of the field to be retrieved as a boolean</param>
|
||||
/// <returns>The specified field as a boolean</returns>
|
||||
public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get a 64-bit integer by its name
|
||||
@ -33,5 +33,19 @@ namespace JobsJobsJobs.Server.Data
|
||||
/// <returns>The specified field as milliseconds</returns>
|
||||
public static Milliseconds GetMilliseconds(this NpgsqlDataReader rdr, string name) =>
|
||||
new Milliseconds(rdr.GetInt64(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get a string by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a string</param>
|
||||
/// <returns>The specified field as a string</returns>
|
||||
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Determine if a column is null
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the column to check</param>
|
||||
/// <returns>True if the column is null, false if not</returns>
|
||||
public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name));
|
||||
}
|
||||
}
|
||||
|
52
src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
Normal file
52
src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the NpgsqlConnection type to support manipulation of profiles
|
||||
/// </summary>
|
||||
public static class ProfileExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a profile object from the given data reader
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which values should be obtained</param>
|
||||
/// <returns>The populated profile</returns>
|
||||
private static Profile ToProfile(NpgsqlDataReader rdr)
|
||||
{
|
||||
var continentId = ContinentId.Parse(rdr.GetString("continent_id"));
|
||||
return new Profile(CitizenId.Parse(rdr.GetString("id")), rdr.GetBoolean("seeking_employment"),
|
||||
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
|
||||
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
|
||||
rdr.GetMilliseconds("last_updated_on"),
|
||||
rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience")))
|
||||
{
|
||||
Continent = new Continent(continentId, rdr.GetString("continent_name"))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve an employment profile by a citizen ID
|
||||
/// </summary>
|
||||
/// <param name="citizen">The ID of the citizen whose profile should be retrieved</param>
|
||||
/// <returns>The profile, or null if it does not exist</returns>
|
||||
public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"SELECT p.*, c.name AS continent_name
|
||||
FROM profile p
|
||||
INNER JOIN continent c ON p.continent_id = c.id
|
||||
WHERE citizen_id = @id";
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
|
||||
|
||||
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null;
|
||||
}
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="5.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />
|
||||
|
@ -1,4 +1,5 @@
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.HttpsPolicy;
|
||||
@ -6,8 +7,10 @@ using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Npgsql;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace JobsJobsJobs.Server
|
||||
{
|
||||
@ -25,8 +28,27 @@ namespace JobsJobsJobs.Server
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb")));
|
||||
services.AddLogging();
|
||||
services.AddControllersWithViews();
|
||||
services.AddRazorPages();
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
}).AddJwtBearer(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "https://jobsjobs.jobs",
|
||||
ValidIssuer = "https://jobsjobs.jobs",
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
|
||||
Configuration.GetSection("Auth")["ServerSecret"]))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
@ -43,7 +65,7 @@ namespace JobsJobsJobs.Server
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseBlazorFrameworkFiles();
|
||||
app.UseStaticFiles();
|
||||
@ -52,6 +74,8 @@ namespace JobsJobsJobs.Server
|
||||
// Alternately, maybe we can even configure the default services and middleware so that it will work
|
||||
// to give us the currently-logged-on user.
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
|
@ -12,5 +12,13 @@ namespace JobsJobsJobs.Shared
|
||||
/// </summary>
|
||||
/// <returns>A new continent ID</returns>
|
||||
public static async Task<ContinentId> Create() => new ContinentId(await ShortId.Create());
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create a continent ID from a string
|
||||
/// </summary>
|
||||
/// <param name="id">The prospective ID</param>
|
||||
/// <returns>The continent ID</returns>
|
||||
/// <exception cref="System.FormatException">If the string is not a valid continent ID</exception>
|
||||
public static ContinentId Parse(string id) => new ContinentId(ShortId.Parse(id));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user