Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45463e56b | ||
|
|
fd2cd68d73 | ||
|
|
b4fbf16869 | ||
|
|
d9130e75a4 | ||
|
|
e2537d77c4 | ||
|
|
fcbc9e78c2 | ||
|
|
9042cd4ead | ||
|
|
1c3de212ff | ||
|
|
72a534a8e0 | ||
|
|
74e55b2f48 | ||
|
|
a3dc58d19b | ||
|
|
5a878bf4b3 | ||
|
|
dee331fb0d |
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"dotnet.defaultSolution": "FiscalOS.slnx",
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -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<IResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace FiscalOS.API.Institutions.Get;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
public IEnumerable<InstitutionDto> Institutions { get; init; } = [];
|
||||
|
||||
[JsonConstructor]
|
||||
private Response()
|
||||
{
|
||||
}
|
||||
|
||||
public static Response From(IEnumerable<InstitutionDto> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ internal static class InstitutionsExtensions
|
||||
institutionsGroup.MapConnectEndpoint();
|
||||
institutionsGroup.MapGetAvailableEndpoint();
|
||||
institutionsGroup.MapLinkEndpoint();
|
||||
institutionsGroup.MapGetEndpoint();
|
||||
|
||||
return institutionsGroup;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using FiscalOS.AppHost;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var api = builder.AddProject<Projects.FiscalOS_API>("API")
|
||||
var api = builder.AddProject<Projects.FiscalOS_API>(ProjectNames.API)
|
||||
.WithHttpHealthCheck("/health");
|
||||
|
||||
builder.AddViteApp("web", "../FiscalOS.Web")
|
||||
builder.AddViteApp(ProjectNames.Web, "../FiscalOS.Web")
|
||||
.WithReference(api)
|
||||
.WithHttpsEndpoint(port: 7061, env: "PORT");
|
||||
|
||||
builder.Build().Run();
|
||||
builder.Build().Run();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace FiscalOS.AppHost;
|
||||
|
||||
public static class ProjectNames
|
||||
{
|
||||
public const string API = "api";
|
||||
public const string Web = "web";
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "auto",
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"singleAttributePerLine": true
|
||||
}
|
||||
|
||||
Generated
+205
-106
@@ -8,7 +8,10 @@
|
||||
"name": "fiscalos-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@jcss/vue-plaid-link": "^1.1.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"ts-results": "^3.3.0",
|
||||
"vue": "beta",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
@@ -1414,6 +1417,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jcss/vue-plaid-link": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jcss/vue-plaid-link/-/vue-plaid-link-1.1.3.tgz",
|
||||
"integrity": "sha512-h6uV2Muh0MdVKeOp/fY2/OYMgy9kQw7JA4HoE/PkfNutVsrO/foXxczLlReUhtPq2erdOepri3Du5W1CKxFc/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.7.2",
|
||||
"vue": "^3.4.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2293,18 +2306,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
||||
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
|
||||
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/type-utils": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/type-utils": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
@@ -2317,8 +2336,8 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
@@ -2333,16 +2352,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
|
||||
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2353,19 +2372,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
|
||||
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
|
||||
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.55.0",
|
||||
"@typescript-eslint/types": "^8.55.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.56.0",
|
||||
"@typescript-eslint/types": "^8.56.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2380,14 +2399,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
|
||||
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
|
||||
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0"
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2398,9 +2417,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
|
||||
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2415,15 +2434,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
|
||||
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
@@ -2435,14 +2454,14 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
|
||||
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
|
||||
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2454,16 +2473,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
|
||||
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
|
||||
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.55.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/project-service": "8.56.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^9.0.5",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2495,16 +2514,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
|
||||
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
|
||||
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0"
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2514,19 +2533,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
|
||||
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
|
||||
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2537,13 +2556,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
|
||||
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -3012,22 +3031,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/eslint-config-typescript": {
|
||||
"version": "14.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.6.0.tgz",
|
||||
"integrity": "sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==",
|
||||
"version": "14.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.7.0.tgz",
|
||||
"integrity": "sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.35.1",
|
||||
"@typescript-eslint/utils": "^8.56.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^9.10.0",
|
||||
"eslint": "^9.10.0 || ^10.0.0",
|
||||
"eslint-plugin-vue": "^9.28.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4"
|
||||
},
|
||||
@@ -3159,6 +3178,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
@@ -3170,9 +3225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -3340,13 +3395,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
@@ -3868,9 +3926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -4111,13 +4169,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-playwright": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.5.1.tgz",
|
||||
"integrity": "sha512-q7oqVQTTfa3VXJQ8E+ln0QttPGrs/XmSO1FjOMzQYBMYF3btih4FIrhEYh34JF184GYDmq3lJ/n7CMa49OHBvA==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.7.0.tgz",
|
||||
"integrity": "sha512-kUgwDZL3knnuJF53WSf5xNnB1aLPnX8furoh0PSrmmFIfMfIMmY3sNd4gtZ2MUUnaIX1/A9ndYtD7bhV1dj+1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"globals": "^16.4.0"
|
||||
"globals": "^17.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -4127,9 +4185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-playwright/node_modules/globals": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
|
||||
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
|
||||
"version": "17.3.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
|
||||
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5145,6 +5203,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5340,11 +5407,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
@@ -6604,6 +6671,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-results": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-results/-/ts-results-3.3.0.tgz",
|
||||
"integrity": "sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -6632,16 +6705,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
|
||||
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
|
||||
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.55.0",
|
||||
"@typescript-eslint/parser": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -6651,7 +6724,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
@@ -7243,6 +7316,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
|
||||
@@ -7294,14 +7393,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
|
||||
"integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
|
||||
"integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@vue-macros/common": "^3.1.1",
|
||||
"@vue/devtools-api": "^8.0.0",
|
||||
"@vue/devtools-api": "^8.0.6",
|
||||
"ast-walker-scope": "^0.8.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -7456,9 +7555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz",
|
||||
"integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==",
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jcss/vue-plaid-link": "^1.1.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"ts-results": "^3.3.0",
|
||||
"vue": "beta",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>FiscalOS</h1>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import App from "../App.vue";
|
||||
|
||||
describe("App", () => {
|
||||
it("mounts renders properly", () => {
|
||||
const wrapper = mount(App);
|
||||
expect(wrapper.text()).toContain("You did it!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import LoginForm from '@/components/LoginForm.vue';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('should have username field', () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
|
||||
const username = wrapper.get('#username');
|
||||
|
||||
expect(username.element.tagName).toBe('INPUT');
|
||||
expect(username.attributes()['type']).toBe('text');
|
||||
});
|
||||
|
||||
it('should have password field', () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
|
||||
const password = wrapper.get('#password');
|
||||
|
||||
expect(password.element.tagName).toBe('INPUT');
|
||||
expect(password.attributes()['type']).toBe('password');
|
||||
});
|
||||
|
||||
it('should require username field on submission', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
});
|
||||
|
||||
it('should require password field on submission', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
});
|
||||
|
||||
it('should reset error state when new input is entered', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
|
||||
expect(wrapper.text()).not.toContain('Username is required');
|
||||
expect(wrapper.text()).not.toContain('Password is required');
|
||||
});
|
||||
|
||||
it('should disable login button while submitting', async () => {
|
||||
let resolveSubmit;
|
||||
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
resolveSubmit = resolve;
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
const submitButton = wrapper.get('button');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect('disabled' in submitButton.attributes()).toBe(true);
|
||||
|
||||
resolveSubmit!();
|
||||
await flushPromises();
|
||||
|
||||
expect('disabled' in submitButton.attributes()).toBe(false);
|
||||
});
|
||||
|
||||
it('should call the onValidSubmit function when submitted with valid state', async () => {
|
||||
let wasCalled = false;
|
||||
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
wasCalled = true;
|
||||
resolve();
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
const submitButton = wrapper.get('button');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(wasCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
@import './reset.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'CaskaydiaCove NFM';
|
||||
src: url('../fonts/CaskaydiaCoveNFM-Regular.eot');
|
||||
src:
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--palette-lavender: #b39cd0;
|
||||
--palette-rose: #d09c9f;
|
||||
--palette-sage: #b9d09c;
|
||||
--palette-mint: #9cd0cd;
|
||||
|
||||
--neutral-white: #ffffff;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
--neutral-black: #000000;
|
||||
|
||||
--bg-app: var(--neutral-white);
|
||||
--bg-surface: var(--neutral-100);
|
||||
--bg-element: var(--neutral-200);
|
||||
|
||||
--text-primary: var(--neutral-black);
|
||||
--text-secondary: var(--neutral-800);
|
||||
--border-subtle: var(--neutral-200);
|
||||
|
||||
--brand-primary: var(--palette-lavender);
|
||||
--brand-secondary: var(--palette-rose);
|
||||
--brand-accent: var(--palette-mint);
|
||||
--state-success: var(--palette-sage);
|
||||
--state-error: var(--palette-rose);
|
||||
|
||||
font-family: 'CaskaydiaCove NFM', monospace;
|
||||
font-size: 16px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-app: var(--neutral-black);
|
||||
--bg-surface: var(--neutral-900);
|
||||
--bg-element: var(--neutral-800);
|
||||
|
||||
--text-primary: var(--neutral-white);
|
||||
--text-secondary: var(--neutral-200);
|
||||
--border-subtle: var(--neutral-800);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 6.4 MiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type LoginFormState = {
|
||||
isLoggingIn: boolean;
|
||||
username: {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
password: {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
|
||||
}>();
|
||||
|
||||
const formState = ref<LoginFormState>({
|
||||
isLoggingIn: false,
|
||||
username: {
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
password: {
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
});
|
||||
|
||||
function handleUsernameInput(e: Event) {
|
||||
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.username.error.trim()) {
|
||||
formState.value.username.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordInput(e: Event) {
|
||||
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.password.error.trim()) {
|
||||
formState.value.password.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormState() {
|
||||
let isValid = true;
|
||||
|
||||
if (!formState.value.username.value.trim()) {
|
||||
formState.value.username.error = 'Username is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formState.value.password.value.trim()) {
|
||||
formState.value.password.error = 'Password is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
formState.value.isLoggingIn = true;
|
||||
|
||||
try {
|
||||
if (validateFormState() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.onValidSubmit !== undefined) {
|
||||
await props.onValidSubmit(formState.value);
|
||||
}
|
||||
|
||||
} finally {
|
||||
formState.value.isLoggingIn = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
|
||||
<div class="error">{{ formState.username.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" :value="formState.password.value"
|
||||
@input="handlePasswordInput" />
|
||||
<div class="error">{{ formState.password.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" :disabled="formState.isLoggingIn">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
form,
|
||||
form>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
form {
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
form>div {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
form>div label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
form>div input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid black;
|
||||
background-color: var(--bg-element);
|
||||
}
|
||||
|
||||
form>div button {
|
||||
background-color: var(--brand-primary);
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
form>div button:disabled {
|
||||
opacity: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
form>div .error {
|
||||
color: var(--state-error);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const asideClasses = computed(() => ({
|
||||
collapsed: userStore.user?.sidebarCollapsed,
|
||||
}));
|
||||
|
||||
function handleToggleButtonClick() {
|
||||
userStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// TODO: Also need to log user
|
||||
// out on the server...which is
|
||||
// basically just clearing
|
||||
// refresh token cookie and revoking
|
||||
// it in the database
|
||||
userStore.logUserOut();
|
||||
router.push({ path: '/public/login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="asideClasses">
|
||||
<button
|
||||
@click="handleToggleButtonClick"
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
<button
|
||||
class="logout-button"
|
||||
type="button"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
aside {
|
||||
--sidebar-width: 15.625rem;
|
||||
--button-size: 1.5rem;
|
||||
--transition-duration: 0.5s;
|
||||
--transition-function: ease-in-out;
|
||||
position: relative;
|
||||
width: var(--sidebar-width);
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
background: var(--bg-surface);
|
||||
transition-property: width, transform;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
aside {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
left: calc(-1 * var(--button-size) / 2);
|
||||
height: var(--button-size);
|
||||
width: var(--button-size);
|
||||
background: var(--bg-element);
|
||||
border-radius: 50%;
|
||||
transition-property: transform;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
aside.collapsed {
|
||||
width: calc(0.125rem + var(--button-size) / 2);
|
||||
}
|
||||
|
||||
aside.collapsed .toggle-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toggle-button svg {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import NavSidebar from './NavSidebar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
<NavSidebar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path
|
||||
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AuthServiceFactoryKey } from "@/services/authService";
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
|
||||
export function useAuthService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (authServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject auth service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
return authService;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import { InstituionServiceFactoryKey } from "@/services/institutionService";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
|
||||
export function useInstitutionService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (institutionServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject institution service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const institutionService = institutionServiceFactory.create(client);
|
||||
|
||||
return institutionService;
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import './assets/css/main.css';
|
||||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { ClientFactory, ClientFactoryKey } from "./services/client";
|
||||
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
|
||||
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.provide(ClientFactoryKey, new ClientFactory());
|
||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
|
||||
@@ -1,8 +1,70 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { AuthService } from '@/services/authService';
|
||||
import { Client, ClientConfig } from '@/services/client';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
routes: [
|
||||
{
|
||||
path: '/public',
|
||||
component: () => import('../components/PublicLayout.vue'),
|
||||
redirect: '/public/login',
|
||||
beforeEnter: () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user) {
|
||||
return { path: '/' };
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../components/ProtectedLayout.vue'),
|
||||
beforeEnter: async () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user === null) {
|
||||
return { path: '/public/login' };
|
||||
}
|
||||
|
||||
const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000;
|
||||
|
||||
if (isExpired) {
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${userStore.user.token}` },
|
||||
true
|
||||
);
|
||||
const client = new Client(clientConfig);
|
||||
const authService = new AuthService(client);
|
||||
const refreshResult = await authService.refreshToken();
|
||||
|
||||
if (refreshResult.err) {
|
||||
userStore.logUserOut();
|
||||
return { path: '/public/login' };
|
||||
}
|
||||
|
||||
userStore.logUserIn(refreshResult.val.accessToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Err, Ok, Result } from 'ts-results';
|
||||
import { type InjectionKey } from 'vue';
|
||||
import { ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type AuthServiceFactoryKeyType = InjectionKey<IAuthServiceFactory>;
|
||||
|
||||
export const AuthServiceFactoryKey: AuthServiceFactoryKeyType = Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IAuthServiceFactory {
|
||||
create: (client: IClient) => IAuthService;
|
||||
}
|
||||
|
||||
export class AuthServiceFactory implements IAuthServiceFactory {
|
||||
create(client: IClient): IAuthService {
|
||||
return new AuthService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAuthService {
|
||||
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
|
||||
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
login: '/api/auth/login',
|
||||
refreshToken: '/api/auth/refresh',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const request = new ClientRequestWithBody(
|
||||
this.endpoints.login,
|
||||
undefined,
|
||||
new LoginRequest(username, password)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const validationErrors = body.errors as Record<string, string[]>;
|
||||
const errors = Object.values(validationErrors)
|
||||
.flat()
|
||||
.map(e => new Error(e));
|
||||
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Email/Password combination is not valid')]);
|
||||
}
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Login failed. Please try again.')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LoginResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Login failed. Please try again.')]);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
const request = new ClientRequestWithBody(this.endpoints.refreshToken, undefined, undefined);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Refresh and/or access token is not valid')]);
|
||||
}
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Refreshing token failed')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LoginResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Refreshing token failed.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BaseAuthRequest {
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
|
||||
constructor(username: string, password: string) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
class LoginRequest extends BaseAuthRequest {
|
||||
constructor(username: string, password: string) {
|
||||
super(username, password);
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResponse = {
|
||||
accessToken: string;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
type ClientKeyType = InjectionKey<IClientFactory>;
|
||||
|
||||
export const ClientFactoryKey: ClientKeyType = Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IClientFactory {
|
||||
create: (config?: ClientConfig) => IClient;
|
||||
}
|
||||
|
||||
export class ClientFactory implements IClientFactory {
|
||||
create(config?: ClientConfig): IClient {
|
||||
return new Client(config);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequest {
|
||||
readonly url: string;
|
||||
readonly config?: RequestInit;
|
||||
|
||||
constructor(url: string, config?: RequestInit) {
|
||||
this.url = url;
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequestWithBody<T> extends ClientRequest {
|
||||
body: T | undefined;
|
||||
|
||||
constructor(url: string, config?: RequestInit, body?: T) {
|
||||
super(url, config);
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export type UnauthorizedResponseHandler =
|
||||
| ((originalRequest: Request) => Promise<{ response: Response; accessToken: string }>)
|
||||
| undefined;
|
||||
|
||||
export class ClientConfig {
|
||||
authHeader: Record<string, string> | undefined;
|
||||
includeCredentials: boolean | undefined;
|
||||
unauthorizedResponseHandler: UnauthorizedResponseHandler;
|
||||
|
||||
constructor(
|
||||
authHeader?: Record<string, string> | undefined,
|
||||
includeCredentials?: boolean | undefined,
|
||||
unauthorizedResponseHandler?: UnauthorizedResponseHandler
|
||||
) {
|
||||
this.authHeader = authHeader;
|
||||
this.includeCredentials = includeCredentials;
|
||||
this.unauthorizedResponseHandler = unauthorizedResponseHandler;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IClient {
|
||||
get: (req: ClientRequest) => Promise<Response>;
|
||||
post: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
put: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
patch: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
delete: (req: ClientRequest) => Promise<Response>;
|
||||
}
|
||||
|
||||
export class Client implements IClient {
|
||||
private readonly _clientConfig: ClientConfig;
|
||||
|
||||
constructor(clientConfig?: ClientConfig) {
|
||||
this._clientConfig = clientConfig ?? new ClientConfig(undefined, true, undefined);
|
||||
}
|
||||
|
||||
private async request(url: string, config?: RequestInit): Promise<Response> {
|
||||
const headers = {
|
||||
...config?.headers,
|
||||
...this._clientConfig?.authHeader,
|
||||
};
|
||||
|
||||
const credentials = this._clientConfig?.includeCredentials
|
||||
? ('include' as RequestCredentials)
|
||||
: ('omit' as RequestCredentials);
|
||||
|
||||
const requestConfig = {
|
||||
...config,
|
||||
headers: headers,
|
||||
credentials: credentials,
|
||||
};
|
||||
|
||||
const firstTryRequest = new Request(url, requestConfig);
|
||||
const secondTryRequest = new Request(url, requestConfig);
|
||||
let response = await fetch(firstTryRequest);
|
||||
|
||||
if (response.status === 401 && this._clientConfig?.unauthorizedResponseHandler) {
|
||||
const retryResult = await this._clientConfig.unauthorizedResponseHandler(secondTryRequest);
|
||||
this._clientConfig.authHeader = { Authorization: `Bearer ${retryResult.accessToken}` };
|
||||
response = retryResult.response;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async get({ url, config }: { url: string; config?: RequestInit }) {
|
||||
const requestConfig = { ...config, method: 'GET' };
|
||||
return await this.request(url, requestConfig);
|
||||
}
|
||||
|
||||
async post<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async put<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async patch<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async delete(req: ClientRequest) {
|
||||
const requestConfig = { ...req?.config, method: 'DELETE' };
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Err, Ok, Result } from 'ts-results';
|
||||
import { type InjectionKey } from 'vue';
|
||||
import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||
|
||||
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
|
||||
Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IInstitutionServiceFactory {
|
||||
create: (client: IClient) => IInstitutionService;
|
||||
}
|
||||
|
||||
export class InstitutionServiceFactory implements InstitutionServiceFactory {
|
||||
create(client: IClient): IInstitutionService {
|
||||
return new InstitutionService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IInstitutionService {
|
||||
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
|
||||
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
|
||||
getInstitutions: () => Promise<Result<Institution[], Error[]>>;
|
||||
}
|
||||
|
||||
export class InstitutionService implements IInstitutionService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
link: '/api/institutions/link',
|
||||
connect: '/api/institutions/connect',
|
||||
institutions: '/api/institutions',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async createLinkToken() {
|
||||
const request = new ClientRequestWithBody(this.endpoints.link);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Failed to create link token')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LinkTokenResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Failed to create link token')]);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(publicToken: string, plaidInstitutionId: string) {
|
||||
const request = new ClientRequestWithBody(this.endpoints.connect, undefined, {
|
||||
publicToken,
|
||||
plaidInstitutionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const validationErrors = body.errors as Record<string, string[]>;
|
||||
const errors = Object.values(validationErrors)
|
||||
.flat()
|
||||
.map(e => new Error(e));
|
||||
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Please sign in and try again.')]);
|
||||
}
|
||||
|
||||
if (res.status === 409) {
|
||||
return Err([new Error('You have already linked this institution.')]);
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AuthServiceFactoryKey } from '@/services/authService';
|
||||
import { ClientConfig, ClientFactoryKey } from '@/services/client';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { defineStore } from 'pinia';
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
export const USER_KEY = 'fiscalos_auth';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
expiresAtInSeconds: number;
|
||||
token: string;
|
||||
sidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
type JwtTokenPayload = {
|
||||
sub: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function getUserFromLocalStorage(): User | null {
|
||||
const user = localStorage.getItem(USER_KEY);
|
||||
return user === null ? null : JSON.parse(user);
|
||||
}
|
||||
|
||||
function saveUserToLocalSotrage(user: User) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export type UserStore = ReturnType<typeof useUserStore>;
|
||||
|
||||
export const useUserStore = defineStore('userStore', () => {
|
||||
const user = ref<User | null>(getUserFromLocalStorage());
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
function logUserIn(jwtToken: string) {
|
||||
const { sub, exp } = jwtDecode<JwtTokenPayload>(jwtToken);
|
||||
const loggedInUser: User = {
|
||||
id: sub,
|
||||
expiresAtInSeconds: exp,
|
||||
token: jwtToken,
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
user.value = loggedInUser;
|
||||
saveUserToLocalSotrage(loggedInUser);
|
||||
}
|
||||
|
||||
function logUserOut() {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(originalRequest: Request) {
|
||||
const unauthorizedResponse = new Response(null, { status: 401 });
|
||||
|
||||
if (user.value === null || !clientFactory || !authServiceFactory) {
|
||||
logUserOut();
|
||||
return { response: unauthorizedResponse, accessToken: '' };
|
||||
}
|
||||
|
||||
const client = clientFactory.create(
|
||||
new ClientConfig({ Authorization: `Bearer ${user.value.token}` }, true)
|
||||
);
|
||||
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
const refreshResult = await authService.refreshToken();
|
||||
|
||||
if (refreshResult.err) {
|
||||
logUserOut();
|
||||
return { response: unauthorizedResponse, accessToken: '' };
|
||||
}
|
||||
|
||||
logUserIn(refreshResult.val.accessToken);
|
||||
|
||||
originalRequest.headers.set('Authorization', `Bearer ${refreshResult.val.accessToken}`);
|
||||
const response = await fetch(originalRequest);
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||
import type { Institution } from '@/services/institutionService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const institutionService = useInstitutionService(userStore);
|
||||
|
||||
type InstitutionData =
|
||||
| {
|
||||
status: 'loading';
|
||||
}
|
||||
| { status: 'loaded'; data: Institution[] }
|
||||
| { status: 'errored'; errors: Error[] };
|
||||
|
||||
const institutionsData = ref<InstitutionData>({ status: 'loading' });
|
||||
|
||||
const plaidOptions = ref<PlaidLinkOptions>({
|
||||
token: '',
|
||||
onSuccess: async (publicToken, metadata) => {
|
||||
if (metadata.institution == null) {
|
||||
alert('Institution information is missing from Plaid response. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectResult = await institutionService.connect(
|
||||
publicToken,
|
||||
metadata.institution.institution_id
|
||||
);
|
||||
|
||||
if (connectResult.err) {
|
||||
alert(connectResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
institutionsData.value = { status: 'loading' };
|
||||
},
|
||||
onLoad: () => console.log('loaded'),
|
||||
onExit: () => console.log('exit'),
|
||||
});
|
||||
const { open } = usePlaidLink(plaidOptions);
|
||||
|
||||
watch(
|
||||
institutionsData,
|
||||
async data => {
|
||||
if (data.status !== 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const institutionsResult = await institutionService.getInstitutions();
|
||||
|
||||
if (institutionsResult.err) {
|
||||
institutionsData.value = {
|
||||
status: 'errored',
|
||||
errors: institutionsResult.val,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
institutionsData.value = {
|
||||
status: 'loaded',
|
||||
data: institutionsResult.val,
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleAddInstitutionClick() {
|
||||
const linkTokenResult = await institutionService.createLinkToken();
|
||||
|
||||
if (linkTokenResult.err) {
|
||||
alert(linkTokenResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
plaidOptions.value = {
|
||||
...plaidOptions.value,
|
||||
token: linkTokenResult.val.linkToken,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
class="add-institution-button"
|
||||
type="button"
|
||||
@click="handleAddInstitutionClick"
|
||||
>
|
||||
Add Institution
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="institutionsData.status === 'loaded'"
|
||||
class="institutions-container"
|
||||
>
|
||||
<div
|
||||
class="institution-card"
|
||||
v-for="institution in institutionsData.data"
|
||||
v-bind:key="institution.id"
|
||||
>
|
||||
<div>
|
||||
<div>{{ institution.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="add-account-button"
|
||||
type="button"
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.institutions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.institution-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.institution-card > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.institution-card > div:last-of-type {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-institution-button,
|
||||
.add-account-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
async function handleValideSubmit(formState: LoginFormState) {
|
||||
const loginResult = await authService.login(formState.username.value, formState.password.value);
|
||||
|
||||
if (loginResult.err) {
|
||||
alert(loginResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
userStore.logUserIn(loginResult.val.accessToken);
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginForm :onValidSubmit="handleValideSubmit" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -7,6 +7,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import mkcert from 'vite-plugin-mkcert';
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: 'static',
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.1.1" />
|
||||
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Playwright.Xunit.v3" Version="1.58.0" />
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
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<Response>(HttpStatusCode.OK))
|
||||
.Which.Institutions.Should().BeEquivalentTo(
|
||||
[
|
||||
InstitutionDto.FromInstitution(institution),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>FiscalOS.AppHost.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Testing" />
|
||||
<PackageReference Include="AwesomeAssertions" />
|
||||
<PackageReference Include="Microsoft.Playwright.Xunit.v3" />
|
||||
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\FiscalOS.AppHost\FiscalOS.AppHost.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
using Aspire.Hosting;
|
||||
using Aspire.Hosting.Testing;
|
||||
|
||||
namespace FiscalOS.AppHost.Tests.Infra;
|
||||
|
||||
public sealed class AspireFixture : IAsyncLifetime
|
||||
{
|
||||
private DistributedApplication _app = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var appHost = await DistributedApplicationTestingBuilder
|
||||
.CreateAsync<Projects.FiscalOS_AppHost>();
|
||||
|
||||
_app = await appHost.BuildAsync();
|
||||
|
||||
await _app.StartAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
|
||||
public Uri GetBaseWebUri()
|
||||
{
|
||||
return _app.GetEndpoint(ProjectNames.Web, "https");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
|
||||
namespace FiscalOS.AppHost.Tests.Infra;
|
||||
|
||||
public abstract class BaseTest(
|
||||
AspireFixture aspire,
|
||||
PlaywrightFixture playwright
|
||||
) : IAsyncLifetime, IClassFixture<AspireFixture>, IClassFixture<PlaywrightFixture>
|
||||
{
|
||||
protected AspireFixture AspireFixture { get; } = aspire;
|
||||
protected PlaywrightFixture PlaywrightFixture { get; } = playwright;
|
||||
|
||||
protected IBrowserContext Context { get; private set; } = null!;
|
||||
protected IPage Page { get; private set; } = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Context = await PlaywrightFixture.Browser.NewContextAsync(new()
|
||||
{
|
||||
BaseURL = AspireFixture.GetBaseWebUri().ToString(),
|
||||
});
|
||||
Page = await Context.NewPageAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Page.CloseAsync();
|
||||
await Context.CloseAsync();
|
||||
await Context.DisposeAsync();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public static ILocatorAssertions Expect(ILocator locator)
|
||||
{
|
||||
return Assertions.Expect(locator);
|
||||
}
|
||||
|
||||
public static IPageAssertions Expect(IPage page)
|
||||
{
|
||||
return Assertions.Expect(page);
|
||||
}
|
||||
|
||||
public static IAPIResponseAssertions Expect(IAPIResponse response)
|
||||
{
|
||||
return Assertions.Expect(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace FiscalOS.AppHost.Tests.Infra;
|
||||
|
||||
public sealed class PlaywrightFixture : IAsyncLifetime
|
||||
{
|
||||
private IPlaywright? _playwright;
|
||||
|
||||
internal IBrowser Browser { get; set; } = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_playwright = await Playwright.CreateAsync();
|
||||
|
||||
Browser = await _playwright.Chromium.LaunchAsync(new()
|
||||
{
|
||||
Headless = false,
|
||||
});
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Browser.CloseAsync();
|
||||
await Browser.DisposeAsync();
|
||||
|
||||
_playwright?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace FiscalOS.AppHost.Tests;
|
||||
|
||||
public class LoginPageTests(
|
||||
AspireFixture aspire,
|
||||
PlaywrightFixture playwright
|
||||
) : BaseTest(aspire, playwright)
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoginPage_WhenNavigatedTo_ItShouldDisplaysLoginButton()
|
||||
{
|
||||
await Page.GotoAsync("/public/login");
|
||||
await Expect(Page.GetByText("Login")).ToBeVisibleAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
global using FiscalOS.AppHost.Tests.Infra;
|
||||
|
||||
global using Microsoft.Playwright;
|
||||
global using Microsoft.Playwright.Xunit.v3;
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
Reference in New Issue
Block a user