diff --git a/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user b/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user
index 1607fbc..8a6c2b4 100644
--- a/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user
+++ b/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user
@@ -3,5 +3,9 @@
RazorPageScaffolder
root/Common/RazorPage
+ JobsJobsJobs
+
+
+ ProjectDebugger
\ No newline at end of file
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor
index 46b0700..82e4609 100644
--- a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor
+++ b/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor
@@ -1,3 +1,36 @@
@page "/citizen/authorized"
+@inject HttpClient http
+@inject NavigationManager nav
+@inject AppState state
-
@Message
+@message
+
+@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\"?)";
+ }
+ }
+}
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs
deleted file mode 100644
index 9f905f6..0000000
--- a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs
+++ /dev/null
@@ -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
- {
- ///
- /// The message to be displayed to the user
- ///
- public string Message { get; set; } = "Logging you on with No Agenda Social...";
-
- ///
- /// HTTP client for performing API calls
- ///
- [Inject]
- public HttpClient Http { get; init; } = default!;
-
- ///
- /// Navigation manager for getting parameters from the URL
- ///
- [Inject]
- public NavigationManager Navigation { get; set; } = default!;
-
- ///
- /// Application state
- ///
- [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())!;
- 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\"?)";
- }
- }
- }
-}
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor
index d9aaea6..33a7a87 100644
--- a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor
+++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor
@@ -1,9 +1,38 @@
@page "/citizen/dashboard"
+@inject HttpClient http
+@inject AppState state
Dashboard
Here's the dashboard, homie...
+@if (hasProfile != null)
+{
+ Has profile? @hasProfile
+}
+
+@if (errorMessage != null)
+{
+ @errorMessage
+}
@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;
+ }
+ }
+ }
}
diff --git a/src/JobsJobsJobs/Client/ServerApi.cs b/src/JobsJobsJobs/Client/ServerApi.cs
new file mode 100644
index 0000000..008f296
--- /dev/null
+++ b/src/JobsJobsJobs/Client/ServerApi.cs
@@ -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
+{
+ ///
+ /// Functions used to access the API
+ ///
+ public static class ServerApi
+ {
+ ///
+ /// Create an API URL
+ ///
+ /// The URL to append to the API base URL
+ /// The full URL to be used in HTTP requests
+ private static string ApiUrl(string url) => $"/api/{url}";
+
+ ///
+ /// Create an HTTP request with an authorization header
+ ///
+ /// The current application state
+ /// The URL for the request (will be appended to the API root)
+ /// The request method (optional, defaults to GET)
+ /// A request with the header attached, ready for further manipulation
+ 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;
+ }
+
+ ///
+ /// Log on a user with the authorization code received from No Agenda Social
+ ///
+ /// The HTTP client to use for server communication
+ /// The authorization code received from NAS
+ /// The log on details if successful, an error if not
+ public static async Task> LogOn(HttpClient http, string authCode)
+ {
+ try
+ {
+ var logOn = await http.GetFromJsonAsync(ApiUrl($"citizen/log-on/{authCode}"));
+ if (logOn == null) {
+ return Result.AsError(
+ "Unable to log on with No Agenda Social. This should never happen; contact @danieljsummers");
+ }
+ return Result.AsOk(logOn);
+ }
+ catch (HttpRequestException ex)
+ {
+ return Result.AsError($"Unable to log on with No Agenda Social: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Retrieve a citizen's profile
+ ///
+ /// The HTTP client to use for server communication
+ /// The current application state
+ /// The citizen's profile, null if it is not found, or an error message if one occurs
+ public static async Task> 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.AsOk(null),
+ _ when res.IsSuccessStatusCode => Result.AsOk(await res.Content.ReadFromJsonAsync()),
+ _ => Result.AsError(await res.Content.ReadAsStringAsync()),
+ };
+ }
+ }
+}
diff --git a/src/JobsJobsJobs/Client/_Imports.razor b/src/JobsJobsJobs/Client/_Imports.razor
index 9000c22..453a6ec 100644
--- a/src/JobsJobsJobs/Client/_Imports.razor
+++ b/src/JobsJobsJobs/Client/_Imports.razor
@@ -9,3 +9,4 @@
@using Microsoft.JSInterop
@using JobsJobsJobs.Client
@using JobsJobsJobs.Client.Shared
+@using JobsJobsJobs.Shared
diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs
index d388b54..33e9b6f 100644
--- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs
+++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs
@@ -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]
+ ///
+ /// The database connection
+ ///
+ private readonly NpgsqlConnection db;
+
+ public ProfileController(NpgsqlConnection dbConn)
+ {
+ db = dbConn;
+ }
+
+ [Authorize]
+ [HttpGet("")]
public async Task Get()
{
- return null;
+ await db.OpenAsync();
+ var profile = await db.FindProfileByCitizen(
+ CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value));
+ return profile == null ? NoContent() : Ok(profile);
}
}
}
diff --git a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs b/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs
index f5bbda4..4d3f588 100644
--- a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs
+++ b/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs
@@ -13,11 +13,11 @@ namespace JobsJobsJobs.Server.Data
public static class NpgsqlDataReaderExtensions
{
///
- /// Get a string by its name
+ /// Get a boolean by its name
///
- /// The name of the field to be retrieved as a string
- /// The specified field as a string
- public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
+ /// The name of the field to be retrieved as a boolean
+ /// The specified field as a boolean
+ public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name));
///
/// Get a 64-bit integer by its name
@@ -33,5 +33,19 @@ namespace JobsJobsJobs.Server.Data
/// The specified field as milliseconds
public static Milliseconds GetMilliseconds(this NpgsqlDataReader rdr, string name) =>
new Milliseconds(rdr.GetInt64(name));
+
+ ///
+ /// Get a string by its name
+ ///
+ /// The name of the field to be retrieved as a string
+ /// The specified field as a string
+ public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
+
+ ///
+ /// Determine if a column is null
+ ///
+ /// The name of the column to check
+ /// True if the column is null, false if not
+ public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name));
}
}
diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
new file mode 100644
index 0000000..b27d777
--- /dev/null
+++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
@@ -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
+{
+ ///
+ /// Extensions to the NpgsqlConnection type to support manipulation of profiles
+ ///
+ public static class ProfileExtensions
+ {
+ ///
+ /// Populate a profile object from the given data reader
+ ///
+ /// The data reader from which values should be obtained
+ /// The populated profile
+ 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"))
+ };
+ }
+
+ ///
+ /// Retrieve an employment profile by a citizen ID
+ ///
+ /// The ID of the citizen whose profile should be retrieved
+ /// The profile, or null if it does not exist
+ public static async Task 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;
+ }
+ }
+}
diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj
index b5c0d9f..e18f2c4 100644
--- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj
+++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/JobsJobsJobs/Server/Startup.cs b/src/JobsJobsJobs/Server/Startup.cs
index ede6aab..af3d54a 100644
--- a/src/JobsJobsJobs/Server/Startup.cs
+++ b/src/JobsJobsJobs/Server/Startup.cs
@@ -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 =>
{
diff --git a/src/JobsJobsJobs/Shared/Domain/ContinentId.cs b/src/JobsJobsJobs/Shared/Domain/ContinentId.cs
index 7d210ab..c43c00c 100644
--- a/src/JobsJobsJobs/Shared/Domain/ContinentId.cs
+++ b/src/JobsJobsJobs/Shared/Domain/ContinentId.cs
@@ -12,5 +12,13 @@ namespace JobsJobsJobs.Shared
///
/// A new continent ID
public static async Task Create() => new ContinentId(await ShortId.Create());
+
+ ///
+ /// Attempt to create a continent ID from a string
+ ///
+ /// The prospective ID
+ /// The continent ID
+ /// If the string is not a valid continent ID
+ public static ContinentId Parse(string id) => new ContinentId(ShortId.Parse(id));
}
}