Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
working-directory: ./frontend
run: npm ci

- name: Generate GraphQL types
working-directory: ./frontend
run: npm run codegen

- name: Check TypeScript compilation
working-directory: ./frontend
run: npx tsc --noEmit
Expand Down
8 changes: 4 additions & 4 deletions PhantomDave.BankTracking.Api/Services/AccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public AccountService(IUnitOfWork unitOfWork)
{
return await _unitOfWork.Accounts.GetSingleOrDefaultAsync(a => a.Email == email);
}

public async Task<IEnumerable<Account>> GetAllAccountsAsync()
{
return await _unitOfWork.Accounts.GetAllAsync();
Expand Down Expand Up @@ -74,11 +74,11 @@ private async Task<bool> IsEmailAlreadyPresent(string email)
public async Task<Account?> LoginAccountAsync(string email, string password)
{
var account = await GetAccountByEmail(email);
if(account == null)
if (account == null)
return null;
return VerifyPassword(password, account.PasswordHash) ? account : null;
}

private static string HashPassword(string password)
{
const int iterations = 100_000;
Expand All @@ -90,7 +90,7 @@ private static string HashPassword(string password)

return $"PBKDF2-SHA256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}

private static bool VerifyPassword(string password, string stored)
{
if (string.IsNullOrWhiteSpace(stored))
Expand Down
45 changes: 45 additions & 0 deletions PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,50 @@ public async Task<AccountType> CreateAccount(

return AccountType.FromAccount(account);
}

/// <summary>
/// Login to an account
/// </summary>
public async Task<AccountType?> LoginAccount(
string email,
string password,
[Service] AccountService accountService)
{
if (string.IsNullOrWhiteSpace(email))
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Email is required.")
.SetCode("BAD_USER_INPUT")
.SetExtension("field", "email")
.SetExtension("reason", "required")
.Build());
}

if (string.IsNullOrWhiteSpace(password))
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Password is required.")
.SetCode("BAD_USER_INPUT")
.SetExtension("field", "password")
.SetExtension("reason", "required")
.Build());
}

var account = await accountService.LoginAccountAsync(email, password);
if (account is null)
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Invalid email or password.")
.SetCode("AUTHENTICATION_FAILED")
.SetExtension("field", "email")
.SetExtension("reason", "invalid_credentials")
.Build());
}

return AccountType.FromAccount(account);
}
}

9 changes: 0 additions & 9 deletions PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,5 @@ public async Task<IEnumerable<AccountType>> GetAccounts(
var account = await accountService.GetAccountByEmail(email);
return account != null ? AccountType.FromAccount(account) : null;
}

public async Task<AccountType?> LoginAccount(
string email,
string password,
[Service] AccountService accountService)
{
var account = await accountService.LoginAccountAsync(email, password);
return account != null ? AccountType.FromAccount(account) : null;
}
}

143 changes: 143 additions & 0 deletions frontend/GRAPHQL_REFACTORING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Account GraphQL Operations Refactoring

This document explains the refactoring of account-related GraphQL operations to use Apollo Angular's typed GQL service pattern.

## Overview

All account operations have been migrated from using raw GraphQL queries/mutations with Apollo client to using generated, type-safe Apollo Angular GQL services.

## What Changed

### Before
```typescript
// Direct Apollo usage
this.apollo.query({
query: GET_ACCOUNT_BY_EMAIL,
variables: { email }
})

this.apollo.mutate({
mutation: CREATE_ACCOUNT,
variables: { email, password }
})
```

### After
```typescript
// Generated typed GQL services
this.getAccountByEmailGQL.fetch({ variables: { email } })
this.createAccountGQL.mutate({ variables: { email, password } })
this.loginAccountGQL.mutate({ variables: { email, password } })
```

## Generated GQL Classes

The following injectable GQL classes are now available in `src/generated/graphql.ts`:

### Queries
- **GetAccountByEmailGQL** - Fetch account by email
- **GetAccountsGQL** - Fetch all accounts

### Mutations
- **CreateAccountGQL** - Create a new account
- **LoginAccountGQL** - Login with email and password (changed from Query to Mutation for proper authentication semantics)

## Usage

### Using AccountService (Recommended)
The AccountService provides high-level methods that wrap the GQL classes with error handling and state management:

```typescript
import { AccountService } from './models/account/account-service';

export class MyComponent {
private readonly accountService = inject(AccountService);

async login() {
const account = await this.accountService.loginAccount(email, password);
}

async register() {
const result = await this.accountService.createAccount(email, password);
}
}
```

### Using GQL Classes Directly (Advanced)
For more control, you can inject the GQL classes directly:

```typescript
import { LoginAccountGQL, CreateAccountGQL } from './generated/graphql';
import { firstValueFrom } from 'rxjs';

export class MyComponent {
private readonly loginGQL = inject(LoginAccountGQL);
private readonly createAccountGQL = inject(CreateAccountGQL);

async login(email: string, password: string) {
const result = await firstValueFrom(
this.loginGQL.watch({ variables: { email, password } }).valueChanges
);
return result.data?.loginAccount;
}

async register(email: string, password: string) {
const result = await firstValueFrom(
this.createAccountGQL.mutate({ variables: { email, password } })
);
return result.data?.createAccount;
}
}
```

## Adding New Operations

To add a new account operation:

1. Create a `.graphql` file in `src/graphql/`:
```graphql
# src/graphql/update-account.mutation.graphql
mutation UpdateAccount($id: Int!, $email: String!) {
updateAccount(id: $id, email: $email) {
id
email
createdAt
updatedAt
}
}
```

2. Run code generation:
```bash
npm run codegen
```

3. The new `UpdateAccountGQL` class will be generated and can be injected:
```typescript
import { UpdateAccountGQL } from './generated/graphql';

export class AccountService {
private readonly updateAccountGQL = inject(UpdateAccountGQL);

async updateAccount(id: number, email: string) {
const result = await firstValueFrom(
this.updateAccountGQL.mutate({ variables: { id, email } })
);
return result.data?.updateAccount;
}
}
```

## Benefits

1. **Type Safety**: Full TypeScript type checking for all GraphQL operations
2. **Auto-completion**: IDE provides suggestions for available fields and variables
3. **Consistency**: All operations follow the same pattern
4. **Maintainability**: Easier to add, modify, and remove operations
5. **Code Generation**: No manual type definitions needed

## Migration Notes

- Old `account.queries.ts` and `account.mutations.ts` files have been removed
- The `Account` interface was updated to match generated types (`updatedAt?: string | null`)
- All existing components using AccountService continue to work without changes
2 changes: 1 addition & 1 deletion frontend/codegen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
schema: 'http://localhost:5095/graphql',
schema: './schema.graphql',
documents: ['src/**/*.graphql'],
generates: {
'./src/generated/graphql.ts': {
Expand Down
2 changes: 1 addition & 1 deletion frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ scalar DateTime

type Mutation {
createAccount(email: String!, password: String!): AccountType!
loginAccount(email: String!, password: String!): AccountType
}

type Query {
accountByEmail(email: String!): AccountType
accounts: [AccountType!]!
loginAccount(email: String!, password: String!): AccountType
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {FlexComponent} from '../../flex-component/flex-component';
import {AccountService} from '../../../../models/account/account-service';

@Component({
selector: 'app-login-component',
Expand All @@ -22,17 +23,17 @@ import {FlexComponent} from '../../flex-component/flex-component';
})
export class LoginComponent {
private readonly fb = inject(FormBuilder);
private readonly accountService = inject(AccountService);

protected readonly loginForm: FormGroup = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]]
});

protected onSubmit() {
protected async onSubmit() {
if (this.loginForm.valid) {
const {email, password} = this.loginForm.value;
console.log('Email:', email);
console.log('Password:', password);
await this.accountService.loginAccount(email, password);
} else {
this.loginForm.markAllAsTouched();
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/graphql/create-account.mutation.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
mutation CreateAccount($email: String!, $password: String!) {
createAccount(email: $email, password: $password) {
id
email
createdAt
updatedAt
}
}
8 changes: 8 additions & 0 deletions frontend/src/graphql/get-account-by-email.query.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
query GetAccountByEmail($email: String!) {
accountByEmail(email: $email) {
id
email
createdAt
updatedAt
}
}
8 changes: 8 additions & 0 deletions frontend/src/graphql/get-accounts.query.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
query GetAccounts {
accounts {
id
email
createdAt
updatedAt
}
}
8 changes: 8 additions & 0 deletions frontend/src/graphql/login-account.mutation.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
mutation LoginAccount($email: String!, $password: String!) {
loginAccount(email: $email, password: $password) {
id
email
createdAt
updatedAt
}
}
Loading
Loading