Merge pull request #17 from StevanFreeborn/stevanfreeborn/feat/display-transactions-in-web

feat: display transactions on the web
This commit is contained in:
Stevan Freeborn
2026-03-11 12:32:41 -05:00
committed by GitHub
79 changed files with 1931 additions and 851 deletions
+11
View File
@@ -0,0 +1,11 @@
.git/
.vscode/
.aspire/
Coverage/
tests/
docker/data/
docker/keys/
**/bin/
**/obj/
**/node_modules/
**/.env
+1 -1
View File
@@ -94,7 +94,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_style_expression_bodied_lambdas = false:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
+94
View File
@@ -0,0 +1,94 @@
name: Deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
WEB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-web
API_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-api
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.web
push: true
tags: |
${{ env.WEB_IMAGE }}:latest
${{ env.WEB_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.api
push: true
tags: |
${{ env.API_IMAGE }}:latest
${{ env.API_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Copy compose file to server
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: docker/docker-compose.prod.yml
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 1
- name: Deploy on server
uses: appleboy/ssh-action@v1
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_TAG: ${{ github.sha }}
JWT_AUDIENCE: ${{ secrets.JWT_AUDIENCE }}
JWT_ISSUER: ${{ secrets.JWT_ISSUER }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_EXPIRY_IN_MINUTES: ${{ secrets.JWT_EXPIRY_IN_MINUTES }}
FILE_KEY_RING_PRIMARY_KEY_ID: ${{ secrets.FILE_KEY_RING_PRIMARY_KEY_ID }}
PLAID_CLIENT_ID: ${{ secrets.PLAID_CLIENT_ID }}
PLAID_SECRET: ${{ secrets.PLAID_SECRET }}
PLAID_WEBHOOK: ${{ secrets.PLAID_WEBHOOK }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: DOCKERHUB_USERNAME,IMAGE_TAG,JWT_AUDIENCE,JWT_ISSUER,JWT_SECRET,JWT_EXPIRY_IN_MINUTES,FILE_KEY_RING_PRIMARY_KEY_ID,PLAID_CLIENT_ID,PLAID_SECRET,PLAID_WEBHOOK
script: |
cd ${{ secrets.DEPLOY_PATH }}
cat > .env << EOF
DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME}
IMAGE_TAG=${IMAGE_TAG}
JWT_AUDIENCE=${JWT_AUDIENCE}
JWT_ISSUER=${JWT_ISSUER}
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRY_IN_MINUTES=${JWT_EXPIRY_IN_MINUTES}
FILE_KEY_RING_PRIMARY_KEY_ID=${FILE_KEY_RING_PRIMARY_KEY_ID}
PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
PLAID_SECRET=${PLAID_SECRET}
PLAID_WEBHOOK=${PLAID_WEBHOOK}
EOF
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
+4
View File
@@ -10,6 +10,10 @@ coverage/
appsettings*.json
!appsettings.Example.json
# docker bind-mount data
docker/data/
docker/keys/
# dotenv files
.env
+1 -1
View File
@@ -11,7 +11,7 @@
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["Encryptor"],
"cSpell.words": ["DOCKERHUB", "Dtos", "Encryptor", "fiscalos", "Validatable"],
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
+1
View File
@@ -15,6 +15,7 @@
<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" />
<Project Path="tests/FiscalOS.Tests.Common/FiscalOS.Tests.Common.csproj" />
</Folder>
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
+38
View File
@@ -0,0 +1,38 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
COPY Directory.Build.props .
COPY global.json .
COPY src/Directory.Build.props src/
COPY src/Directory.Packages.props src/
COPY src/FiscalOS.Core/FiscalOS.Core.csproj src/FiscalOS.Core/
COPY src/FiscalOS.Infra/FiscalOS.Infra.csproj src/FiscalOS.Infra/
COPY src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj src/FiscalOS.ServiceDefaults/
COPY src/FiscalOS.API/FiscalOS.API.csproj src/FiscalOS.API/
COPY src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj src/FiscalOS.AdminCLI/
RUN dotnet restore src/FiscalOS.API/FiscalOS.API.csproj && \
dotnet restore src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj
COPY src/ src/
RUN dotnet publish src/FiscalOS.API/FiscalOS.API.csproj \
-c Release \
-o /publish/api \
--no-restore && \
dotnet publish src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj \
-c Release \
-o /publish/admincli \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /publish/api .
COPY --from=build /publish/admincli /usr/local/lib/admincli
ENV PATH="/usr/local/lib/admincli:${PATH}"
EXPOSE 8080
ENTRYPOINT ["dotnet", "FiscalOS.API.dll"]
+13
View File
@@ -0,0 +1,13 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY src/FiscalOS.Web/package.json src/FiscalOS.Web/package-lock.json ./
RUN npm ci
COPY src/FiscalOS.Web/ .
RUN npm run build
FROM nginx:alpine AS final
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+27
View File
@@ -0,0 +1,27 @@
services:
web:
image: ${DOCKERHUB_USERNAME}/fiscalos-web:${IMAGE_TAG:-latest}
ports:
- "8081:80"
depends_on:
- api
restart: unless-stopped
api:
image: ${DOCKERHUB_USERNAME}/fiscalos-api:${IMAGE_TAG:-latest}
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
+33
View File
@@ -0,0 +1,33 @@
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
ports:
- "80:80"
depends_on:
- api
restart: unless-stopped
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -1,3 +1,5 @@
using FiscalOS.API.Accounts.Add;
namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions
+6 -6
View File
@@ -24,9 +24,9 @@ internal static class Endpoint
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.ProviderInstitutionId))
.ThenInclude(i => i.Metadata)
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.ProviderAccountId))
.ThenInclude(a => a.Metadata)
.AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == userId, ct);
@@ -40,7 +40,7 @@ internal static class Endpoint
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
@@ -50,7 +50,7 @@ internal static class Endpoint
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
@@ -60,8 +60,8 @@ internal static class Endpoint
}
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
var account = Account.From(request.PlaidAccountName, accountMetadata);
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName);
var account = Account.From(request.ProviderAccountName, accountMetadata);
user.AddAccount(account);
user.Institutions.First().AddAccount(account);
+9 -9
View File
@@ -2,27 +2,27 @@ namespace FiscalOS.API.Accounts.Add;
public record Request : IValidatableObject
{
public string PlaidInstitutionId { get; init; } = string.Empty;
public string PlaidAccountId { get; init; } = string.Empty;
public string PlaidAccountName { get; init; } = string.Empty;
public string ProviderInstitutionId { get; init; } = string.Empty;
public string ProviderAccountId { get; init; } = string.Empty;
public string ProviderAccountName { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
if (string.IsNullOrWhiteSpace(ProviderInstitutionId))
{
var fieldName = nameof(PlaidInstitutionId);
var fieldName = nameof(ProviderInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidAccountId))
if (string.IsNullOrWhiteSpace(ProviderAccountId))
{
var fieldName = nameof(PlaidAccountId);
var fieldName = nameof(ProviderAccountId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidAccountName))
if (string.IsNullOrWhiteSpace(ProviderAccountName))
{
var fieldName = nameof(PlaidAccountName);
var fieldName = nameof(ProviderAccountName);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
+4
View File
@@ -1,3 +1,7 @@
using FiscalOS.API.Auth.Login;
using FiscalOS.API.Auth.Logout;
using FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Auth;
internal static class AuthExtensions
+2
View File
@@ -1,3 +1,5 @@
namespace FiscalOS.API.Auth.Logout;
internal static class Endpoint
{
private const string Route = "/logout";
+36
View File
@@ -0,0 +1,36 @@
namespace FiscalOS.API.Common;
internal sealed record PagedQuery
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public static async ValueTask<PagedQuery> BindAsync(HttpContext context)
{
var pageNumber = int.TryParse(context.Request.Query["pageNumber"], out var pn) ? pn : 1;
var pageSize = int.TryParse(context.Request.Query["pageSize"], out var ps) ? ps : 1000;
return new PagedQuery
{
PageNumber = pageNumber,
PageSize = pageSize
};
}
public Dictionary<string, string[]> Validate()
{
var validationResults = new Dictionary<string, string[]>();
if (PageNumber <= 0)
{
validationResults[nameof(PageNumber)] = ["PageNumber must be greater than 0."];
}
if (PageSize <= 0 || PageSize > 1000)
{
validationResults[nameof(PageSize)] = ["PageSize must be between 1 and 1000."];
}
return validationResults;
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace FiscalOS.API.Common;
internal sealed record PagedResponse<T>
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalItems { get; init; }
public int TotalPages { get; init; }
public T[] Items { get; init; } = [];
[JsonConstructor]
private PagedResponse()
{
}
public static PagedResponse<T> From(
int pageNumber,
int pageSize,
int totalItems,
T[] items
)
{
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
return new PagedResponse<T>
{
PageNumber = pageNumber,
PageSize = pageSize,
TotalItems = totalItems,
TotalPages = totalPages,
Items = items
};
}
}
@@ -19,6 +19,7 @@ internal static class Endpoint
var user = await appDbContext.Users
.Include(u => u.Institutions)
.ThenInclude(i => i.Accounts)
.ThenInclude(a => a.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
@@ -24,6 +24,7 @@ internal sealed record Response
internal sealed record AccountDto
{
public string Id { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
@@ -36,6 +37,9 @@ internal sealed record AccountDto
return new()
{
Id = account.Id.ToString(),
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
? plaidMetadata.PlaidId
: string.Empty,
Name = account.Name,
};
}
@@ -1,3 +1,8 @@
using FiscalOS.API.Institutions.Connect;
using FiscalOS.API.Institutions.Get;
using FiscalOS.API.Institutions.GetAvailable;
using FiscalOS.API.Institutions.Link;
namespace FiscalOS.API.Institutions;
internal static class InstitutionsExtensions
@@ -0,0 +1,53 @@
namespace FiscalOS.API.Transactions.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,
PagedQuery pagedQuery,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var pagedQueryValidationResults = pagedQuery.Validate();
if (pagedQueryValidationResults.Count is not 0)
{
return Results.ValidationProblem(pagedQueryValidationResults);
}
var userId = httpContext.GetUserId();
var query = appDbContext.Transactions
.Where(t => t.UserId == userId);
var transactions = await query
.Skip((pagedQuery.PageNumber - 1) * pagedQuery.PageSize)
.Take(pagedQuery.PageSize)
// TODO: Migrate Date to DateTime
// to avoid in-memory sorting
.AsAsyncEnumerable()
.OrderByDescending(t => t.Date)
.ToListAsync(ct);
var count = await query
.Where(t => t.UserId == userId)
.CountAsync(ct);
var transactionDtos = transactions.Select(TransactionDto.From).ToArray();
var pagedResponse = PagedResponse<TransactionDto>.From(
pagedQuery.PageNumber,
pagedQuery.PageSize,
count,
transactionDtos
);
return Results.Ok(pagedResponse);
}
}
@@ -0,0 +1,29 @@
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.API.Transactions;
internal sealed record TransactionDto
{
public Guid Id { get; init; }
public string MerchantName { get; init; } = string.Empty;
public decimal Amount { get; init; }
public DateTimeOffset Date { get; init; }
public string Description { get; init; } = string.Empty;
[JsonConstructor]
private TransactionDto()
{
}
public static TransactionDto From(Transaction transaction)
{
return new TransactionDto
{
Id = transaction.Id,
MerchantName = transaction.MerchantName,
Amount = transaction.Amount,
Date = transaction.Date,
Description = transaction.Description
};
}
}
@@ -1,3 +1,7 @@
using FiscalOS.API.Transactions.FireWebhook;
using FiscalOS.API.Transactions.Get;
using FiscalOS.API.Transactions.Webhook;
namespace FiscalOS.API.Transactions;
internal static class TransactionsExtensions
@@ -9,13 +13,14 @@ internal static class TransactionsExtensions
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
transactionsGroup.MapGetEndpoint();
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
if (app.Environment.IsProduction() is false)
{
transactionsGroup.MapFireWebhookEndpoint();
}
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
return transactionsGroup;
}
}
+1 -9
View File
@@ -3,19 +3,11 @@ global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth;
global using FiscalOS.API.Auth.Login;
global using FiscalOS.API.Auth.Refresh;
global using FiscalOS.API.Common;
global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions;
global using FiscalOS.API.Institutions.Connect;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Institutions.GetAvailable;
global using FiscalOS.API.Institutions.Link;
global using FiscalOS.API.Transactions;
global using FiscalOS.API.Transactions.FireWebhook;
global using FiscalOS.API.Transactions.Webhook;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
-6
View File
@@ -1,6 +0,0 @@
import { test, expect } from '@playwright/test'
test('visits the app root url', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('You did it!')
})
+8 -10
View File
@@ -1,10 +1,9 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
import { globalIgnores } from 'eslint/config';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import pluginVue from 'eslint-plugin-vue';
import pluginVitest from '@vitest/eslint-plugin';
import pluginOxlint from 'eslint-plugin-oxlint';
import skipFormatting from 'eslint-config-prettier/flat';
export default defineConfigWithVueTs(
{
@@ -15,7 +14,6 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
@@ -23,5 +21,5 @@ export default defineConfigWithVueTs(
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
skipFormatting
);
+123 -218
View File
@@ -16,7 +16,6 @@
"vue-router": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
@@ -29,7 +28,6 @@
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
@@ -1206,9 +1204,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1280,9 +1278,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1851,22 +1849,6 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1882,9 +1864,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1896,9 +1878,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1910,9 +1892,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1924,9 +1906,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1938,9 +1920,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1952,9 +1934,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1966,9 +1948,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1980,9 +1962,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1994,9 +1976,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -2008,9 +1990,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -2022,9 +2004,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -2036,9 +2018,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -2050,9 +2032,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -2064,9 +2046,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -2078,9 +2060,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -2092,9 +2074,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -2106,9 +2088,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -2120,9 +2102,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -2134,9 +2116,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -2148,9 +2130,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -2162,9 +2144,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -2176,9 +2158,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -2190,9 +2172,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -2204,9 +2186,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -2218,9 +2200,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -3257,9 +3239,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3878,15 +3860,15 @@
"license": "MIT"
},
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "9.0.1",
"minimatch": "^9.0.1",
"semver": "^7.5.3"
},
"bin": {
@@ -3896,22 +3878,6 @@
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/editorconfig/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -4168,35 +4134,6 @@
"jsonc-parser": "^3.3.1"
}
},
"node_modules/eslint-plugin-playwright": {
"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": "^17.3.0"
},
"engines": {
"node": ">=16.9.0"
},
"peerDependencies": {
"eslint": ">=8.40.0"
}
},
"node_modules/eslint-plugin-playwright/node_modules/globals": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz",
@@ -4297,9 +4234,9 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -5391,13 +5328,13 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -5929,38 +5866,6 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -6155,9 +6060,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6171,31 +6076,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
-3
View File
@@ -8,7 +8,6 @@
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
@@ -25,7 +24,6 @@
"vue-router": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
@@ -38,7 +36,6 @@
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
-45
View File
@@ -1,45 +0,0 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
actionTimeout: 0,
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
trace: 'on-first-retry',
headless: !!process.env.CI,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
],
webServer: {
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { RouterView } from 'vue-router';
</script>
<template>
@@ -68,9 +68,10 @@ describe('LoginForm', () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
resolveSubmit = resolve;
}),
onValidSubmit: () =>
new Promise(resolve => {
resolveSubmit = resolve;
}),
},
});
@@ -96,10 +97,11 @@ describe('LoginForm', () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
wasCalled = true;
resolve();
}),
onValidSubmit: () =>
new Promise(resolve => {
wasCalled = true;
resolve();
}),
},
});
+18
View File
@@ -24,6 +24,24 @@ input {
color: inherit;
}
select {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option :hover {
background-color: var(--bg-element);
}
a {
text-decoration: none;
color: inherit;
@@ -0,0 +1,76 @@
<script setup lang="ts">
import CircleSpinner from '@/components/CircleSpinner.vue';
import type { AvailableAccount } from '@/services/institutionService';
import { ref, watchEffect } from 'vue';
const props = defineProps<{
availableAccounts: AvailableAccount[];
isSubmitting: boolean;
}>();
const emit = defineEmits<{
submit: [account: AvailableAccount];
}>();
const selectedAccount = ref<AvailableAccount | null>(null);
const selectRef = ref<HTMLSelectElement | null>(null);
watchEffect(() => {
if (props.availableAccounts.length > 0) {
selectRef.value?.focus();
}
});
function handleSubmit() {
if (selectedAccount.value === null) {
return;
}
emit('submit', selectedAccount.value);
}
</script>
<template>
<form
class="add-account-form"
@submit.prevent="handleSubmit"
>
<label for="account-select">Select account to add:</label>
<select
id="account-select"
ref="selectRef"
v-model="selectedAccount"
>
<option
:value="null"
disabled
hidden
>
Select an account
</option>
<option
v-for="account in availableAccounts"
:key="account.providerId"
:value="account"
>
{{ account.providerName }}
</option>
</select>
<button
class="add-account-button"
type="submit"
:disabled="isSubmitting"
>
<CircleSpinner v-if="isSubmitting" />
<span v-else>Add</span>
</button>
</form>
</template>
<style scoped>
.add-account-form {
display: flex;
gap: 0.25rem;
align-items: center;
}
</style>
@@ -0,0 +1,21 @@
<template>
<span class="spinner" />
</template>
<style scoped>
.spinner {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import AddAccountForm from '@/components/AddAccountForm.vue';
import CircleSpinner from '@/components/CircleSpinner.vue';
import { useAccountService } from '@/composables/useAccountService';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import { ref } from 'vue';
const props = defineProps<{
institution: Institution;
}>();
const emit = defineEmits<{
accountAdded: [];
}>();
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
const accountService = useAccountService(userStore);
const loading = ref(false);
const addingAccount = ref(false);
const availableAccounts = ref<AvailableAccount[]>([]);
const showAddForm = ref(false);
async function handleAddAccountClick() {
loading.value = true;
const getAccountsResult = await institutionService.getAvailableAccounts(props.institution.id);
loading.value = false;
if (getAccountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
const notAlreadyAddedAccounts = getAccountsResult.val.filter(a => {
return props.institution.accounts.some(ac => ac.providerId === a.providerId) === false;
});
availableAccounts.value = notAlreadyAddedAccounts;
showAddForm.value = true;
}
async function handleFormSubmit(account: AvailableAccount) {
addingAccount.value = true;
try {
const addAccountResult = await accountService.add(
account.providerInstitutionId,
account.providerId,
account.providerName
);
if (addAccountResult.err) {
alert(addAccountResult.val.map(e => e.message).join('\n'));
return;
}
showAddForm.value = false;
emit('accountAdded');
} finally {
addingAccount.value = false;
}
}
</script>
<template>
<div class="institution-container">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button
class="add-account-button"
type="button"
:disabled="loading"
@click="handleAddAccountClick"
>
<CircleSpinner v-if="loading" />
<span v-else>Add Account</span>
</button>
</div>
</div>
<div class="accounts-list">
<div
class="account-row"
v-for="account in institution.accounts"
:key="account.id"
>
<div class="account-card">{{ account.name }}</div>
</div>
</div>
<div
class="add-account-card"
v-if="showAddForm"
>
<AddAccountForm
:availableAccounts="availableAccounts"
:isSubmitting="addingAccount"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
<style scoped>
.institution-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.institution-card,
.account-card,
.add-account-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card,
.account-card {
position: relative;
}
.institution-card > div {
flex: 1;
}
.institution-card > div:last-of-type {
display: flex;
justify-content: flex-end;
}
.institution-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
.institution-card::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
height: calc(50% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.add-account-card {
margin-left: 2rem;
gap: 0.5rem;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.account-row {
position: relative;
padding-left: 2rem;
}
.account-row::before {
content: '';
position: absolute;
top: 0;
left: -1px;
height: calc(100% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.account-row:not(:first-child)::before {
top: -0.5rem;
height: calc(100% + 1rem);
}
.account-row:last-child::before {
height: 50%;
}
.account-row:not(:first-child):last-child::before {
top: -0.5rem;
height: calc(50% + 0.5rem);
}
.account-row::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
width: calc(2rem + 1px);
height: 2px;
background: var(--brand-primary);
transform: translateY(-50%);
}
.account-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
</style>
+115 -102
View File
@@ -1,99 +1,112 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref } from 'vue';
export type LoginFormState = {
isLoggingIn: boolean;
username: {
value: string;
error: string;
export type LoginFormState = {
isLoggingIn: boolean;
username: {
value: string;
error: string;
};
password: {
value: string;
error: string;
};
};
password: {
value: string;
error: string;
};
};
const props = defineProps<{
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
}>();
const props = defineProps<{
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
}>();
const formState = ref<LoginFormState>({
isLoggingIn: false,
username: {
value: '',
error: '',
},
password: {
value: '',
error: '',
},
});
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;
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.username.error.trim()) {
formState.value.username.error = '';
}
}
if (!formState.value.password.value.trim()) {
formState.value.password.error = 'Password is required';
isValid = false;
function handlePasswordInput(e: Event) {
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.password.error.trim()) {
formState.value.password.error = '';
}
}
return isValid;
}
function validateFormState() {
let isValid = true;
async function handleSubmit() {
formState.value.isLoggingIn = true;
try {
if (validateFormState() === false) {
return;
if (!formState.value.username.value.trim()) {
formState.value.username.error = 'Username is required';
isValid = false;
}
if (props.onValidSubmit !== undefined) {
await props.onValidSubmit(formState.value);
if (!formState.value.password.value.trim()) {
formState.value.password.error = 'Password is required';
isValid = false;
}
} finally {
formState.value.isLoggingIn = 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" />
<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" />
<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">
<button
type="submit"
:disabled="formState.isLoggingIn"
>
Login
</button>
</div>
@@ -101,47 +114,47 @@ async function handleSubmit() {
</template>
<style scoped>
form,
form>div {
display: flex;
flex-direction: column;
background-color: var(--bg-surface);
}
form,
form > div {
display: flex;
flex-direction: column;
background-color: var(--bg-surface);
}
form {
gap: 1rem;
padding: 1rem;
border-radius: 0.25rem;
}
form {
gap: 1rem;
padding: 1rem;
border-radius: 0.25rem;
}
form>div {
gap: 0.25rem;
}
form > div {
gap: 0.25rem;
}
form>div label {
font-weight: 700;
}
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 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 {
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 button:disabled {
opacity: 50%;
pointer-events: none;
}
form>div .error {
color: var(--state-error);
font-size: 0.85rem;
}
form > div .error {
color: var(--state-error);
font-size: 0.85rem;
}
</style>
+178 -78
View File
@@ -1,102 +1,202 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import RightArrowBracketIcon from './icons/RightArrowBracketIcon.vue';
const userStore = useUserStore();
const router = useRouter();
const authService = useAuthService(userStore);
const userStore = useUserStore();
const router = useRouter();
const authService = useAuthService(userStore);
const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed,
}));
const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed,
}));
function handleToggleButtonClick() {
userStore.toggleSidebar();
}
async function handleLogout() {
const logoutResult = await authService.logout();
if (logoutResult.err) {
alert(logoutResult.val.map(e => e.message).join('\n'));
return;
function handleToggleButtonClick() {
userStore.toggleSidebar();
}
userStore.logUserOut();
router.push({ path: '/public/login' });
}
async function handleLogout() {
const logoutResult = await authService.logout();
if (logoutResult.err) {
alert(logoutResult.val.map(e => e.message).join('\n'));
return;
}
userStore.logUserOut();
router.push({ path: '/public/login' });
}
</script>
<template>
<aside :class="asideClasses">
<button @click="handleToggleButtonClick" type="button" class="toggle-button">
<button
@click="handleToggleButtonClick"
type="button"
class="toggle-button"
>
<LeftArrowIcon />
</button>
<button class="logout-button" type="button" @click="handleLogout">
Logout
</button>
<div class="sidebar-content">
<div class="top">
<nav>
<ul>
<li>
<RouterLink to="/accounts">Accounts</RouterLink>
</li>
<li>
<RouterLink to="/transactions">Transactions</RouterLink>
</li>
</ul>
</nav>
</div>
<div class="bottom">
<button
class="logout-button"
type="button"
@click="handleLogout"
>
<RightArrowBracketIcon />
Logout
</button>
</div>
</div>
</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;
--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);
}
}
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
@media screen and (max-width: 48rem) {
aside {
position: absolute;
right: 0;
}
}
.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);
}
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: 3rem;
flex: 1;
overflow: hidden;
opacity: 1;
visibility: visible;
transition-property: opacity, visibility;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
.top {
padding: 1rem;
}
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
nav ul {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
}
nav a {
display: flex;
width: 100%;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
nav a.router-link-exact-active,
nav a:hover {
background: var(--bg-element);
}
nav a.router-link-exact-active {
position: relative;
}
nav a.router-link-exact-active::before {
content: ' ';
position: absolute;
left: 0;
height: 1.25rem;
background: red;
border: 2px solid var(--brand-primary);
}
.bottom {
padding: 1rem;
}
.logout-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
background: var(--bg-element);
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
.logout-button svg {
--size: 1.5rem;
width: var(--size);
height: var(--size);
fill: var(--text-primary);
}
.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);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
fill: var(--text-primary);
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
aside.collapsed .sidebar-content {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
</style>
@@ -22,5 +22,16 @@
main {
flex: 1;
padding: 1rem;
padding-right: 0rem;
overflow: auto;
scroll-behavior: smooth;
scrollbar-color: var(--bg-surface) var(--bg-app);
scrollbar-gutter: stable;
}
@media screen and (max-width: 48rem) {
main {
margin-right: 1rem;
}
}
</style>
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { RouterView } from 'vue-router';
</script>
<template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Transaction } from '@/services/transactionService';
defineProps<{
transaction: Transaction;
}>();
function formatDate(date: string) {
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return formatter.format(new Date(date));
}
function formatAmount(amount: number): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
return formatter.format(amount);
}
</script>
<template>
<div class="card">
<div class="top">
<div class="left">
<div>{{ transaction.merchantName }}</div>
<div class="date">{{ formatDate(transaction.date) }}</div>
</div>
<div class="right">
<div>{{ formatAmount(transaction.amount) }}</div>
</div>
</div>
<div class="bottom">
<p>{{ transaction.description }}</p>
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--bg-surface);
border-radius: 0.25rem;
padding: 1rem;
}
.top {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date,
.bottom {
font-size: 0.875rem;
color: #666666;
}
</style>
@@ -0,0 +1,10 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
>
<path
d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"
/>
</svg>
</template>
@@ -1,6 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<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" />
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,7 @@
import { AccountServiceFactoryKey } from '@/services/accountService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useAccountService(store: UserStore) {
return useService(store, AccountServiceFactoryKey);
}
@@ -1,27 +1,7 @@
import { AuthServiceFactoryKey } from "@/services/authService";
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
import { AuthServiceFactoryKey } from '@/services/authService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
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;
return useService(store, AuthServiceFactoryKey);
}
@@ -1,27 +1,7 @@
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import { InstituionServiceFactoryKey } from "@/services/institutionService";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
import { InstitutionServiceFactoryKey } from '@/services/institutionService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
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;
return useService(store, InstitutionServiceFactoryKey);
}
@@ -0,0 +1,33 @@
import type { InjectionKey } from 'vue';
import { inject } from 'vue';
import { ClientConfig, ClientFactoryKey, type IClient } from '@/services/client';
import type { UserStore } from '@/stores/userStore';
export interface IServiceFactory<TService> {
create: (client: IClient) => TService;
}
export function useService<TService>(
store: UserStore,
serviceFactoryKey: InjectionKey<IServiceFactory<TService>>
): TService {
const clientFactory = inject(ClientFactoryKey);
const serviceFactory = inject(serviceFactoryKey);
if (clientFactory === undefined) {
throw new Error('Failed to inject client factory.');
}
if (serviceFactory === undefined) {
throw new Error('Failed to inject service factory.');
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}` },
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
return serviceFactory.create(client);
}
@@ -0,0 +1,7 @@
import { TransactionServiceFactoryKey } from '@/services/transactionService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useTransactionService(store: UserStore) {
return useService(store, TransactionServiceFactoryKey);
}
+19 -9
View File
@@ -1,21 +1,31 @@
import './assets/css/main.css';
import { createApp } from "vue";
import { createPinia } from "pinia";
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';
import App from './App.vue';
import router from './router';
import { ClientFactory, ClientFactoryKey } from './services/client';
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
import {
InstitutionServiceFactoryKey,
InstitutionServiceFactory,
} from './services/institutionService';
import {
TransactionServiceFactory,
TransactionServiceFactoryKey,
} from './services/transactionService';
import { AccountServiceFactory, AccountServiceFactoryKey } from './services/accountService';
const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.provide(InstitutionServiceFactoryKey, new InstitutionServiceFactory());
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
app.provide(AccountServiceFactoryKey, new AccountServiceFactory());
app.use(createPinia());
app.use(router);
app.mount("#app");
app.mount('#app');
+23 -1
View File
@@ -23,6 +23,9 @@ const router = createRouter({
{
path: 'login',
component: () => import('../views/LoginView.vue'),
meta: {
title: 'Login',
},
},
],
},
@@ -60,11 +63,30 @@ const router = createRouter({
children: [
{
path: '/',
component: () => import('../views/HomeView.vue'),
redirect: '/accounts',
},
{
path: '/accounts',
component: () => import('../views/AccountsView.vue'),
meta: {
title: 'Accounts',
},
},
{
path: '/transactions',
component: () => import('../views/TransactionsView.vue'),
meta: {
title: 'Transactions',
},
},
],
},
],
});
router.beforeEach((to, _, next) => {
document.title = to.meta.title ? `FiscalOS - ${to.meta.title}` : 'FiscalOS';
next();
});
export default router;
@@ -0,0 +1,58 @@
import type { InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
import { Err, Ok, type Result } from 'ts-results';
type AccountServiceFactoryKeyType = InjectionKey<IAccountServiceFactory>;
export const AccountServiceFactoryKey: AccountServiceFactoryKeyType =
Symbol('AccountServiceFactory');
export interface IAccountServiceFactory {
create: (client: IClient) => IAccountService;
}
export class AccountServiceFactory implements IAccountServiceFactory {
create(client: IClient): IAccountService {
return new AccountService(client);
}
}
export interface IAccountService {
add: (
providerInstitutionId: string,
providerAccountId: string,
providerAccountName: string
) => Promise<Result<boolean, Error[]>>;
}
export class AccountService implements IAccountService {
private readonly client: IClient;
private readonly endpoints = {
add: '/api/accounts',
};
constructor(client: IClient) {
this.client = client;
}
async add(providerInstitutionId: string, providerAccountId: string, providerAccountName: string) {
const request = new ClientRequestWithBody(this.endpoints.add, undefined, {
providerInstitutionId,
providerAccountId,
providerAccountName,
});
try {
const res = await this.client.post(request);
if (res.ok === false) {
return Err([new Error('Failed to add account.')]);
}
return Ok(true);
} catch (error) {
console.error(error);
return Err([new Error('Failed to add account.')]);
}
}
}
@@ -4,8 +4,9 @@ import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
Symbol('AuthServiceFactory');
export const InstitutionServiceFactoryKey: InstitutionServiceFactoryKeyType = Symbol(
'InstitutionServiceFactory'
);
export interface IInstitutionServiceFactory {
create: (client: IClient) => IInstitutionService;
@@ -136,8 +137,9 @@ type LinkTokenResponse = {
type Account = {
id: string;
providerId: string;
name: string;
}
};
export type Institution = {
id: string;
@@ -0,0 +1,73 @@
import type { InjectionKey } from 'vue';
import { ClientRequest, type IClient } from './client';
import { Err, Ok, type Result } from 'ts-results';
type TransactionServiceFactoryKeyType = InjectionKey<ITransactionServiceFactory>;
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType = Symbol(
'TransactionServiceFactory'
);
export interface ITransactionServiceFactory {
create: (client: IClient) => ITransactionService;
}
export class TransactionServiceFactory implements ITransactionServiceFactory {
create(client: IClient): ITransactionService {
return new TransactionService(client);
}
}
export interface ITransactionService {
get: (pageNumber?: number, pageSize?: number) => Promise<Result<Page<Transaction>, Error[]>>;
}
export class TransactionService implements ITransactionService {
private readonly client: IClient;
private readonly endpoints = {
get: '/api/transactions',
};
constructor(client: IClient) {
this.client = client;
}
async get(pageNumber: number = 1, pageSize: number = 500) {
const queryParams = new URLSearchParams({
pageNumber: pageNumber.toString(),
pageSize: pageSize.toString(),
});
const url = this.endpoints.get + '?' + queryParams.toString();
const request = new ClientRequest(url);
try {
const res = await this.client.get(request);
if (res.ok === false) {
return Err([new Error('Failed to retrieve transactions.')]);
}
const data = await res.json();
return Ok(data as Page<Transaction>);
} catch (error) {
console.error(error);
return Err([new Error('Failed to retrieve transactions.')]);
}
}
}
export type Page<T> = {
pageNumber: number;
pageSize: number;
totalItems: number;
totalPages: number;
items: T[];
};
export type Transaction = {
id: string;
merchantName: string;
description: string;
amount: number;
date: string;
};
+1 -1
View File
@@ -88,7 +88,7 @@ export const useUserStore = defineStore('userStore', () => {
const updatedUser = {
...user.value,
sidebarCollapsed: !user.value?.sidebarCollapsed,
}
};
user.value = updatedUser;
saveUserToLocalSotrage(updatedUser);
}
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import InstitutionCard from '@/components/InstitutionCard.vue';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import {
usePlaidLink,
type PlaidLinkOnSuccessMetadata,
type PlaidLinkOptions,
} from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
type InstitutionData =
| { status: 'loading' }
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
async function handleSuccess(publicToken: string, metadata: PlaidLinkOnSuccessMetadata) {
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' };
}
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: handleSuccess,
});
const { open } = usePlaidLink(plaidOptions);
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
async function institutionsDataWatcher(data: InstitutionData) {
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 };
}
watch(institutionsData, institutionsDataWatcher, { 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 class="header">
<h1>Accounts</h1>
<button
class="add-institution-button"
type="button"
@click="handleAddInstitutionClick"
>
Add Institution
</button>
</div>
<div
v-if="institutionsData.status === 'loaded'"
class="institutions-container"
>
<InstitutionCard
v-for="institution in institutionsData.data"
:key="institution.id"
:institution="institution"
@accountAdded="institutionsData = { status: 'loading' }"
/>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.header {
display: flex;
gap: 1rem;
align-items: center;
}
.institutions-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, 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();
}
const targetInstitutionOfAdd = ref<Institution | null>(null);
const availableAccounts = ref<AvailableAccount[]>([]);
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
async function handleAddAccountClick(institution: Institution) {
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
if (accountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
availableAccounts.value = accountsResult.val;
targetInstitutionOfAdd.value = institution;
}
async function handleAccountAddClick() {
if (targetAccountOfAdd.value === null) {
return;
}
const request = {
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
plaidAccountId: targetAccountOfAdd.value.providerId,
plaidAccountName: targetAccountOfAdd.value.providerName,
accountAvailableBalance: targetAccountOfAdd.value.availableBalance,
accountCurrencyCode: targetAccountOfAdd.value.currencyCode,
};
try {
const res = await fetch('/api/accounts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userStore.user?.token}`,
},
body: JSON.stringify(request),
});
if (res.ok === false) {
alert('Add failed');
return;
}
targetInstitutionOfAdd.value = null;
institutionsData.value = { status: 'loading' };
} catch (error) {
console.error(error);
}
}
</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 v-for="institution in institutionsData.data" v-bind:key="institution.id">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button class="add-account-button" type="button" @click="handleAddAccountClick(institution)">
Add Account
</button>
</div>
</div>
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
<select v-model="targetAccountOfAdd">
<option v-for="account in availableAccounts" v-bind:key="account.providerId" :value="account">
{{ account.providerName }}
</option>
</select>
<button type="button" @click="handleAccountAddClick">
Add
</button>
</div>
<div v-for="accounts in institution.accounts" v-bind:key="accounts.id">
<div>{{ accounts.name }}</div>
</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>
+16 -16
View File
@@ -1,24 +1,24 @@
<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';
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);
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);
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;
if (loginResult.err) {
alert(loginResult.val.map(e => e.message).join('\n'));
return;
}
userStore.logUserIn(loginResult.val.accessToken);
router.push('/');
}
userStore.logUserIn(loginResult.val.accessToken);
router.push('/');
}
</script>
<template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import TransactionCard from '@/components/TransactionCard.vue';
import { useTransactionService } from '@/composables/useTransactionService';
import type { Transaction } from '@/services/transactionService';
import { useUserStore } from '@/stores/userStore';
import { onMounted, ref } from 'vue';
const userStore = useUserStore();
const transactionService = useTransactionService(userStore);
const transactions = ref<Transaction[]>([]);
onMounted(async () => {
const result = await transactionService.get();
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
transactions.value = result.val.items;
});
</script>
<template>
<div>
<h1>Transactions</h1>
<ul class="transactions">
<li
v-for="transaction in transactions"
:key="transaction.id"
>
<TransactionCard :transaction="transaction" />
</li>
</ul>
</div>
</template>
<style scoped>
.transactions {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>
+1
View File
@@ -10,6 +10,7 @@
<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.extensibility.core" Version="3.2.2" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
</ItemGroup>
</Project>
@@ -22,6 +22,7 @@
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup>
</Project>
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.WebUtilities;
namespace FiscalOS.API.Tests.Infra;
internal sealed class HttpRequestBuilder
@@ -8,6 +10,7 @@ internal sealed class HttpRequestBuilder
private string? _bearerToken;
private readonly Dictionary<string, string> _cookies = [];
private readonly Dictionary<string, string> _headers = [];
private readonly Dictionary<string, string?> _queryParameters = [];
private HttpRequestBuilder()
{
@@ -68,6 +71,12 @@ internal sealed class HttpRequestBuilder
return this;
}
public HttpRequestBuilder WithQueryParameter(string name, string value)
{
_queryParameters[name] = value;
return this;
}
public HttpRequestBuilder Post(Uri uri)
{
_method = HttpMethod.Post;
@@ -103,7 +112,11 @@ internal sealed class HttpRequestBuilder
throw new InvalidOperationException("URI must be set before building the request.");
}
var request = new HttpRequestMessage(_method, _uri);
var uri = _queryParameters.Count > 0
? QueryHelpers.AddQueryString(_uri.ToString(), _queryParameters)
: _uri.ToString();
var request = new HttpRequestMessage(_method, uri);
if (_body is not null)
{
@@ -1,3 +1,4 @@
using FiscalOS.API.Accounts.Add;
using FiscalOS.Core.Queuing;
using FiscalOS.Infra.Transactions.Plaid;
@@ -31,9 +32,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
});
}
@@ -45,9 +46,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidAccountId = "accountId",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerAccountId = "accountId",
providerAccountName = "Some Account",
})
.Build();
@@ -55,7 +55,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
});
}
@@ -67,9 +67,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidInstitutionId = "institutionId",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerInstitutionId = "institutionId",
providerAccountName = "Some Account",
})
.Build();
@@ -77,7 +76,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
});
}
@@ -89,9 +88,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidInstitutionId = "institutionId",
plaidAccountId = "accountId",
accountCurrencyCode = "USD",
providerInstitutionId = "institutionId",
providerAccountId = "accountId",
})
.Build();
@@ -99,7 +97,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
});
}
@@ -111,10 +109,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidInstitutionId = "id",
plaidAccountId = "id",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerInstitutionId = "id",
providerAccountId = "id",
providerAccountName = "Some Account",
})
.Build();
@@ -145,10 +142,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id)
.WithBody(new
{
plaidInstitutionId = "id",
plaidAccountId = "id",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerInstitutionId = "id",
providerAccountId = "id",
providerAccountName = "Some Account",
})
.Build();
@@ -156,7 +152,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
@@ -199,10 +195,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id)
.WithBody(new
{
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
accountCurrencyCode = "USD",
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
providerAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
providerAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
})
.Build();
@@ -252,20 +247,15 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
var newAccountId = "newAccountId";
var newAccountName = "New Account";
var expectedBalance = 100;
var expectedCurrencyCode = "USD";
using var request = HttpRequestBuilder.New()
.Post(AddUri)
.WithUserId(user.Id)
.WithBody(new
{
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
plaidAccountId = newAccountId,
plaidAccountName = newAccountName,
accountCurrentBalance = expectedBalance,
accountAvailableBalance = expectedBalance,
accountCurrencyCode = expectedCurrencyCode,
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
providerAccountId = newAccountId,
providerAccountName = newAccountName,
})
.Build();
@@ -0,0 +1,101 @@
using FiscalOS.API.Transactions;
namespace FiscalOS.API.Tests.Integration.Transactions;
public class GetTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri GetUri = new("/transactions", UriKind.Relative);
[Fact]
public async Task Get_WhenNotLoggedIn_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Get_WhenCalledWithInvalidPageNumber_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithQueryParameter("pageNumber", "-1")
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PageNumber"] = ["PageNumber must be greater than 0."],
});
}
[Theory]
[InlineData(0)]
[InlineData(1001)]
public async Task Get_WhenCalledWithInvalidPageSize_ItShouldReturn400WithProblemDetails(int pageSize)
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithQueryParameter("pageSize", pageSize.ToString(CultureInfo.InvariantCulture))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PageSize"] = ["PageSize must be between 1 and 1000."],
});
}
[Fact]
public async Task Get_WhenCalled_ItShouldReturn200WithPagedResponseOfTransactions()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var user = UserBuilder.Create()
.WithInstitution(static ib =>
{
ib.WithMetadata();
ib.WithAccount(static ab =>
{
ab.WithMetadata();
ab.WithTransaction(static tb =>
{
tb.WithMetadata();
});
});
})
.Build();
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithUserId(user.Id)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
(await response.Should()
.BeJsonContentOfType<PagedResponse<TransactionDto>>(HttpStatusCode.OK))
.Which
.Items
.Should()
.BeEquivalentTo(
user.Transactions.Select(TransactionDto.From)
);
}
}
@@ -0,0 +1,21 @@
namespace FiscalOS.API.Tests.Unit;
public class PagedResponseTests
{
[Fact]
public void From_WhenCalled_ItShouldReturnPageWithCorrectValues()
{
var pageNumber = 1;
var pageSize = 10;
var totalItems = 25;
string[] items = ["test"];
var results = PagedResponse<string>.From(pageNumber, pageSize, totalItems, items);
results.PageNumber.Should().Be(pageNumber);
results.PageSize.Should().Be(pageSize);
results.TotalItems.Should().Be(totalItems);
results.TotalPages.Should().Be(3);
results.Items.Should().BeEquivalentTo(items);
}
}
+3
View File
@@ -1,3 +1,4 @@
global using System.Globalization;
global using System.IdentityModel.Tokens.Jwt;
global using System.Net;
global using System.Net.Http.Headers;
@@ -10,6 +11,7 @@ global using System.Text.Json;
global using AwesomeAssertions.Execution;
global using AwesomeAssertions.Primitives;
global using FiscalOS.API.Common;
global using FiscalOS.API.Tests.Assertions;
global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication;
@@ -18,6 +20,7 @@ global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
global using FiscalOS.Tests.Common.Data;
global using Going.Plaid;
global using Going.Plaid.Entity;
@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,3 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Integration;
public abstract class IntegrationTest : IAsyncLifetime
@@ -1,5 +1,3 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionSyncerTests
+1
View File
@@ -19,6 +19,7 @@ global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Tests.Assertions;
global using FiscalOS.Infra.Tests.Mocks;
global using FiscalOS.Tests.Common.Data;
global using FiscalOS.Infra.Transactions.Plaid;
global using Going.Plaid;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class AccountBuilder
public sealed class AccountBuilder
{
private string _name = "accountName";
private AccountMetadata? _metadata;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class AccountMetadataBuilder
public sealed class AccountMetadataBuilder
{
private string _plaidId = "plaidId";
private string _plaidName = "plaidName";
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class InstitutionBuilder
public sealed class InstitutionBuilder
{
private string _name = "institutionName";
private InstitutionMetadata? _metadata;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class InstitutionMetadataBuilder
public sealed class InstitutionMetadataBuilder
{
private string _plaidId = "plaidId";
private string _plaidName = "plaidName";
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class TransactionBuilder
public sealed class TransactionBuilder
{
private string _merchantName = "merchantName";
private string _description = "description";
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class TransactionMetadataBuilder
public sealed class TransactionMetadataBuilder
{
private string _plaidId = "plaidId";
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class UserBuilder
public sealed class UserBuilder
{
private string _username = "username";
private string _password = "hashedPassword";
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Transactions.Plaid;