From fd2cd68d73bcbe12373b80f2225f955761f4e064 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:49:04 -0600 Subject: [PATCH] feat(web,api): added ability to add and then display list of institutions --- src/FiscalOS.API/Institutions/Get/Endpoint.cs | 34 +++ src/FiscalOS.API/Institutions/Get/Response.cs | 39 ++++ .../Institutions/InstitutionsExtensions.cs | 1 + src/FiscalOS.API/Usings.cs | 1 + src/FiscalOS.Web/src/assets/css/reset.css | 1 + .../src/components/NavSidebar.vue | 35 ++- .../src/components/ProtectedLayout.vue | 2 +- .../src/services/institutionService.ts | 28 ++- src/FiscalOS.Web/src/stores/userStore.ts | 22 +- src/FiscalOS.Web/src/views/HomeView.vue | 200 ++++++++++++------ .../Integration/Institutions/GetTests.cs | 71 +++++++ 11 files changed, 364 insertions(+), 70 deletions(-) create mode 100644 src/FiscalOS.API/Institutions/Get/Endpoint.cs create mode 100644 src/FiscalOS.API/Institutions/Get/Response.cs create mode 100644 tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs diff --git a/src/FiscalOS.API/Institutions/Get/Endpoint.cs b/src/FiscalOS.API/Institutions/Get/Endpoint.cs new file mode 100644 index 0000000..a315cd2 --- /dev/null +++ b/src/FiscalOS.API/Institutions/Get/Endpoint.cs @@ -0,0 +1,34 @@ +namespace FiscalOS.API.Institutions.Get; + +internal static class Endpoint +{ + private const string Route = "/"; + + public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapGet(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + [FromServices] AppDbContext appDbContext + ) + { + var userId = httpContext.GetUserId(); + + var user = await appDbContext.Users + .Include(u => u.Institutions) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user is null) + { + return Results.Unauthorized(); + } + + var institutionDtos = user.Institutions + .Select(InstitutionDto.FromInstitution) + .OrderBy(dto => dto.Name); + + return Results.Ok(Response.From(institutionDtos)); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Institutions/Get/Response.cs b/src/FiscalOS.API/Institutions/Get/Response.cs new file mode 100644 index 0000000..85d5dac --- /dev/null +++ b/src/FiscalOS.API/Institutions/Get/Response.cs @@ -0,0 +1,39 @@ +namespace FiscalOS.API.Institutions.Get; + +internal sealed record Response +{ + public IEnumerable Institutions { get; init; } = []; + + [JsonConstructor] + private Response() + { + } + + public static Response From(IEnumerable institutions) + { + return new Response + { + Institutions = institutions, + }; + } +} + +internal sealed record InstitutionDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + + [JsonConstructor] + private InstitutionDto() + { + } + + public static InstitutionDto FromInstitution(Institution institution) + { + return new InstitutionDto + { + Id = institution.Id.ToString(), + Name = institution.Name, + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs index ebb063e..d783b9f 100644 --- a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs +++ b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs @@ -12,6 +12,7 @@ internal static class InstitutionsExtensions institutionsGroup.MapConnectEndpoint(); institutionsGroup.MapGetAvailableEndpoint(); institutionsGroup.MapLinkEndpoint(); + institutionsGroup.MapGetEndpoint(); return institutionsGroup; } diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index 5135bcd..80d5a1a 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -2,6 +2,7 @@ global using System.ComponentModel.DataAnnotations; global using System.Security.Claims; global using System.Text.Json.Serialization; +global using FiscalOS.API.Institutions.Get; global using FiscalOS.API.Accounts; global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Auth; diff --git a/src/FiscalOS.Web/src/assets/css/reset.css b/src/FiscalOS.Web/src/assets/css/reset.css index 82de911..53390eb 100644 --- a/src/FiscalOS.Web/src/assets/css/reset.css +++ b/src/FiscalOS.Web/src/assets/css/reset.css @@ -15,6 +15,7 @@ button { cursor: pointer; font-family: inherit; font-size: inherit; + color: inherit; } input { diff --git a/src/FiscalOS.Web/src/components/NavSidebar.vue b/src/FiscalOS.Web/src/components/NavSidebar.vue index f830b11..8fe66e5 100644 --- a/src/FiscalOS.Web/src/components/NavSidebar.vue +++ b/src/FiscalOS.Web/src/components/NavSidebar.vue @@ -1,14 +1,28 @@ @@ -21,6 +35,13 @@ > + @@ -47,6 +68,12 @@ } } + .logout-button { + background: var(--bg-element); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + } + .toggle-button { display: flex; align-items: center; diff --git a/src/FiscalOS.Web/src/components/ProtectedLayout.vue b/src/FiscalOS.Web/src/components/ProtectedLayout.vue index 9a25f47..747a75c 100644 --- a/src/FiscalOS.Web/src/components/ProtectedLayout.vue +++ b/src/FiscalOS.Web/src/components/ProtectedLayout.vue @@ -21,6 +21,6 @@ main { flex: 1; - border: 1px solid blue; + padding: 1rem; } diff --git a/src/FiscalOS.Web/src/services/institutionService.ts b/src/FiscalOS.Web/src/services/institutionService.ts index f898567..916848c 100644 --- a/src/FiscalOS.Web/src/services/institutionService.ts +++ b/src/FiscalOS.Web/src/services/institutionService.ts @@ -1,6 +1,6 @@ import { Err, Ok, Result } from 'ts-results'; import { type InjectionKey } from 'vue'; -import { ClientRequestWithBody, type IClient } from './client'; +import { ClientRequest, ClientRequestWithBody, type IClient } from './client'; type InstitutionServiceFactoryKeyType = InjectionKey; @@ -20,6 +20,7 @@ export class InstitutionServiceFactory implements InstitutionServiceFactory { export interface IInstitutionService { createLinkToken: () => Promise>; connect: (publicToken: string, plaidInstitutionId: string) => Promise>; + getInstitutions: () => Promise>; } export class InstitutionService implements IInstitutionService { @@ -27,6 +28,7 @@ export class InstitutionService implements IInstitutionService { private readonly endpoints = { link: '/api/institutions/link', connect: '/api/institutions/connect', + institutions: '/api/institutions', }; constructor(client: IClient) { @@ -84,8 +86,32 @@ export class InstitutionService implements IInstitutionService { return Err([new Error('Failed to connect institution')]); } } + + async getInstitutions() { + const request = new ClientRequest(this.endpoints.institutions); + + try { + const res = await this.client.get(request); + + if (res.ok === false) { + return Err([new Error('Failed to get institutions')]); + } + + const data = await res.json(); + + return Ok(data.institutions as Institution[]); + } catch (e) { + console.error(e); + return Err([new Error('Failed to get institutions')]); + } + } } type LinkTokenResponse = { linkToken: string; }; + +export type Institution = { + id: string; + name: string; +}; diff --git a/src/FiscalOS.Web/src/stores/userStore.ts b/src/FiscalOS.Web/src/stores/userStore.ts index a4abcfe..750cb2a 100644 --- a/src/FiscalOS.Web/src/stores/userStore.ts +++ b/src/FiscalOS.Web/src/stores/userStore.ts @@ -10,6 +10,7 @@ export type User = { id: string; expiresAtInSeconds: number; token: string; + sidebarCollapsed: boolean; }; type JwtTokenPayload = { @@ -22,6 +23,10 @@ function getUserFromLocalStorage(): User | null { return user === null ? null : JSON.parse(user); } +function saveUserToLocalSotrage(user: User) { + localStorage.setItem(USER_KEY, JSON.stringify(user)); +} + export type UserStore = ReturnType; export const useUserStore = defineStore('userStore', () => { @@ -35,9 +40,10 @@ export const useUserStore = defineStore('userStore', () => { id: sub, expiresAtInSeconds: exp, token: jwtToken, + sidebarCollapsed: false, }; - localStorage.setItem(USER_KEY, JSON.stringify(loggedInUser)); user.value = loggedInUser; + saveUserToLocalSotrage(loggedInUser); } function logUserOut() { @@ -74,10 +80,24 @@ export const useUserStore = defineStore('userStore', () => { return { response, accessToken: refreshResult.val.accessToken }; } + async function toggleSidebar() { + if (user.value === null) { + return; + } + + const updatedUser = { + ...user.value, + sidebarCollapsed: !user.value?.sidebarCollapsed, + } + user.value = updatedUser; + saveUserToLocalSotrage(updatedUser); + } + return { user: user, logUserIn, logUserOut, refreshAccessToken, + toggleSidebar, }; }); diff --git a/src/FiscalOS.Web/src/views/HomeView.vue b/src/FiscalOS.Web/src/views/HomeView.vue index 23a86c6..4deddcf 100644 --- a/src/FiscalOS.Web/src/views/HomeView.vue +++ b/src/FiscalOS.Web/src/views/HomeView.vue @@ -1,82 +1,156 @@ diff --git a/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs new file mode 100644 index 0000000..8086ace --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs @@ -0,0 +1,71 @@ +using FiscalOS.API.Institutions.Get; + +using Institution = FiscalOS.Core.Accounts.Institution; + +namespace FiscalOS.API.Tests.Integration.Institutions; + +public class GetTests(TestApi testApi) : IntegrationTest(testApi) +{ + private static readonly Uri GetUri = new("/institutions", UriKind.Relative); + + [Fact] + public async Task Get_WhenCalledWithoutValidToken_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .Build(); + + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Get_WhenCalledByNonExistentUser_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .WithUserId(Guid.NewGuid()) + .Build(); + + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Get_WhenCalledByUser_ItShouldReturn200WithListOfInstitutions() + { + var (user, institution) = await ExecuteAsync(static async (context, ct, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); + + var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); + var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); + var institution = Institution.From("Some Bank", plaidMetadata); + + user.AddInstitution(institution); + + await context.AddAsync(user, ct); + await context.SaveChangesAsync(ct); + return (user, institution); + }, TestContext.Current.CancellationToken); + + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .WithUserId(user.Id) + .Build(); + + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + (await res.Should().BeJsonContentOfType(HttpStatusCode.OK)) + .Which.Institutions.Should().BeEquivalentTo( + [ + InstitutionDto.FromInstitution(institution), + ]); + } +} \ No newline at end of file