Merge pull request #17 from StevanFreeborn/stevanfreeborn/feat/display-transactions-in-web
feat: display transactions on the web
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.git/
|
||||
.vscode/
|
||||
.aspire/
|
||||
Coverage/
|
||||
tests/
|
||||
docker/data/
|
||||
docker/keys/
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/.env
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,10 @@ coverage/
|
||||
appsettings*.json
|
||||
!appsettings.Example.json
|
||||
|
||||
# docker bind-mount data
|
||||
docker/data/
|
||||
docker/keys/
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
namespace FiscalOS.API.Auth.Logout;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/logout";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
@@ -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!')
|
||||
})
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Generated
+123
-218
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -70,4 +67,4 @@
|
||||
"@vue/shared": "beta",
|
||||
"@vue/compat": "beta"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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;
|
||||
};
|
||||
@@ -88,7 +88,7 @@ export const useUserStore = defineStore('userStore', () => {
|
||||
const updatedUser = {
|
||||
...user.value,
|
||||
sidebarCollapsed: !user.value?.sidebarCollapsed,
|
||||
}
|
||||
};
|
||||
user.value = updatedUser;
|
||||
saveUserToLocalSotrage(updatedUser);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
+2
-2
@@ -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";
|
||||
+2
-2
@@ -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;
|
||||
+2
-2
@@ -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";
|
||||
+2
-2
@@ -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";
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+2
-2
@@ -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>
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user