Repository: butskristof/split-the-bill
Pull request #31: Set up API client in frontend
Author: login: butskristof
Created: 2025-04-01T22:27:29Z | Merged: 2025-04-01T22:33:09Z



Diff:
diff --git a/api/src/1-core/Application/Common/Configuration/CorsSettings.cs b/api/src/1-core/Application/Common/Configuration/CorsSettings.cs
new file mode 100644
index 0000000..c04b357
--- /dev/null
+++ b/api/src/1-core/Application/Common/Configuration/CorsSettings.cs
@@ -0,0 +1,9 @@
+namespace SplitTheBill.Application.Common.Configuration;
+
+public sealed record CorsSettings : ISettings
+{
+    public static string SectionName => "Cors";
+
+    public required bool AllowCors { get; init; }
+    public required string[] AllowedOrigins { get; init; }
+}
\ No newline at end of file
diff --git a/api/src/1-core/Application/Common/Configuration/ISettings.cs b/api/src/1-core/Application/Common/Configuration/ISettings.cs
new file mode 100644
index 0000000..1c0cbf7
--- /dev/null
+++ b/api/src/1-core/Application/Common/Configuration/ISettings.cs
@@ -0,0 +1,6 @@
+namespace SplitTheBill.Application.Common.Configuration;
+
+public interface ISettings
+{
+    static abstract string SectionName { get; }
+}
\ No newline at end of file
diff --git a/api/src/1-core/Application/Common/Dto/GroupDto.cs b/api/src/1-core/Application/Common/Dto/GroupDto.cs
deleted file mode 100644
index 9949db9..0000000
--- a/api/src/1-core/Application/Common/Dto/GroupDto.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace SplitTheBill.Application.Common.Dto;
-
-public sealed record GroupDto(Guid Id, string Name);
\ No newline at end of file
diff --git a/api/src/1-core/Application/Common/Dto/MemberDto.cs b/api/src/1-core/Application/Common/Dto/MemberDto.cs
deleted file mode 100644
index 6ec45a2..0000000
--- a/api/src/1-core/Application/Common/Dto/MemberDto.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace SplitTheBill.Application.Common.Dto;
-
-public sealed record MemberDto(Guid Id, string Name);
\ No newline at end of file
diff --git a/api/src/1-core/Application/Modules/Groups/GetGroups.cs b/api/src/1-core/Application/Modules/Groups/GetGroups.cs
index eb61f7b..01aa6c2 100644
--- a/api/src/1-core/Application/Modules/Groups/GetGroups.cs
+++ b/api/src/1-core/Application/Modules/Groups/GetGroups.cs
@@ -11,8 +11,11 @@ public static class GetGroups
     public sealed record Request : IRequest<ErrorOr<Response>>;
 
     public sealed record Response(
-        List<Common.Dto.GroupDto> Groups
-    );
+        List<Response.GroupDto> Groups
+    )
+    {
+        public sealed record GroupDto(Guid Id, string Name);
+    }
 
     internal sealed class Handler : IRequestHandler<Request, ErrorOr<Response>>
     {
@@ -39,7 +42,7 @@ public async Task<ErrorOr<Response>> Handle(Request request, CancellationToken c
             var groups = await _dbContext
                 .Groups
                 .AsNoTracking()
-                .Select(g => new Common.Dto.GroupDto(g.Id, g.Name))
+                .Select(g => new Response.GroupDto(g.Id, g.Name))
                 .ToListAsync(cancellationToken);
             _logger.LogDebug("Fetched mapped Group entities from database");
 
diff --git a/api/src/1-core/Application/Modules/Members/GetMembers.cs b/api/src/1-core/Application/Modules/Members/GetMembers.cs
index 3dbe262..b183710 100644
--- a/api/src/1-core/Application/Modules/Members/GetMembers.cs
+++ b/api/src/1-core/Application/Modules/Members/GetMembers.cs
@@ -2,7 +2,6 @@
 using MediatR;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Logging;
-using SplitTheBill.Application.Common.Dto;
 using SplitTheBill.Application.Common.Persistence;
 
 namespace SplitTheBill.Application.Modules.Members;
@@ -12,8 +11,11 @@ public static class GetMembers
     public sealed record Request : IRequest<ErrorOr<Response>>;
 
     public sealed record Response(
-        List<MemberDto> Members
-    );
+        List<Response.MemberDto> Members
+    )
+    {
+        public sealed record MemberDto(Guid Id, string Name);
+    }
 
     internal sealed class Handler : IRequestHandler<Request, ErrorOr<Response>>
     {
@@ -40,7 +42,7 @@ public async Task<ErrorOr<Response>> Handle(Request request, CancellationToken c
             var members = await _dbContext
                 .Members
                 .AsNoTracking()
-                .Select(m => new MemberDto(m.Id, m.Name))
+                .Select(m => new Response.MemberDto(m.Id, m.Name))
                 .ToListAsync(cancellationToken);
             _logger.LogDebug("Fetched mapped Member entities from database");
 
diff --git a/api/src/3-presentation/Api/DependencyInjection.cs b/api/src/3-presentation/Api/DependencyInjection.cs
index 0a079d6..7fb2369 100644
--- a/api/src/3-presentation/Api/DependencyInjection.cs
+++ b/api/src/3-presentation/Api/DependencyInjection.cs
@@ -1,5 +1,7 @@
-using Microsoft.AspNetCore.OpenApi;
+using Microsoft.Extensions.Options;
 using SplitTheBill.Api.Constants;
+using SplitTheBill.Api.Extensions;
+using SplitTheBill.Application.Common.Configuration;
 
 namespace SplitTheBill.Api;
 
@@ -9,9 +11,14 @@ internal static class DependencyInjection
 
     internal static IServiceCollection AddConfiguration(this IServiceCollection services)
     {
+        services.AddValidatedSettings<CorsSettings>();
         return services;
     }
 
+    private static IServiceCollection AddValidatedSettings<TOptions>(this IServiceCollection services)
+        where TOptions : class, ISettings
+        => services.AddValidatedSettings<TOptions>(TOptions.SectionName);
+
     private static IServiceCollection AddValidatedSettings<TOptions>(this IServiceCollection services,
         string sectionName)
         where TOptions : class
@@ -34,38 +41,16 @@ internal static IServiceCollection AddApi(this IServiceCollection services)
         // add runtime-generated OpenAPI documentation
         services.AddOpenApi(ApiConstants.OpenApiDocumentName, options =>
         {
-            // since almost all the in- and outgoing types are named Request and Response, we'll use 
-            // the full name of the type as schema ID 
-            // start by using the default implementation and only replace the value if it is not null 
-            // (this will exclude primitive types which shouldn't explicitly be listed in the specification)
-            options.CreateSchemaReferenceId = type =>
+            options.CustomSchemaIds(type =>
             {
-                var defaultSchemaReferenceId = OpenApiOptions.CreateDefaultSchemaReferenceId(type);
-                if (string.IsNullOrWhiteSpace(defaultSchemaReferenceId)) return null;
-                var schemaReferenceId = type.Type.FullName;
-                if (string.IsNullOrWhiteSpace(schemaReferenceId)) return null;
-
-                // inner classes such as request are joined with + instead of .
-                schemaReferenceId = schemaReferenceId.Replace('+', '.');
-                // remove common prefixes (SplitTheBill.Application.Modules e.g.)
-                // by splitting them into parts, we can progressively shorten the result and given we 
-                // hold to a similar structure (e.g. Application/Modules and Api/Modules), this can
-                // effectively result in a uniform collection of schema IDs
-                string[] prefixesToDelete =
-                [
-                    nameof(SplitTheBill),
-                    nameof(Application),
-                    nameof(Api),
-                    nameof(Modules)
-                ];
-                foreach (var prefix in prefixesToDelete)
-                {
-                    if (schemaReferenceId.StartsWith(prefix + "."))
-                        schemaReferenceId = schemaReferenceId.Remove(0, prefix.Length + 1);
-                }
-
-                return schemaReferenceId;
-            };
+                // build the full type name by traversing up its declaring types, this will result in e.g.
+                // "CreateExpense.Request.Participant"
+                var schemaId = type.Name;
+                for (Type current = type; current.DeclaringType is not null; current = current.DeclaringType)
+                    schemaId = $"{current.DeclaringType.Name}.{schemaId}";
+
+                return schemaId;
+            });
         });
 
         // add support for ProblemDetails to handle failed requests
@@ -78,6 +63,22 @@ internal static IServiceCollection AddApi(this IServiceCollection services)
         services
             .AddHealthChecks();
 
+        // TODO remove after BFF/Proxy setup
+        services.AddCors(options =>
+        {
+            using var serviceProvider = services.BuildServiceProvider();
+            var settings = serviceProvider
+                .GetRequiredService<IOptions<CorsSettings>>()
+                .Value;
+            if (!settings.AllowCors) return;
+
+            options.AddDefaultPolicy(policy => policy
+                .WithOrigins(settings.AllowedOrigins)
+                .AllowAnyHeader()
+                .AllowAnyMethod()
+            );
+        });
+
         return services;
     }
 
diff --git a/api/src/3-presentation/Api/Extensions/OpenApiExtensions.cs b/api/src/3-presentation/Api/Extensions/OpenApiExtensions.cs
index 6e7c10d..b5bf4b9 100644
--- a/api/src/3-presentation/Api/Extensions/OpenApiExtensions.cs
+++ b/api/src/3-presentation/Api/Extensions/OpenApiExtensions.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.OpenApi;
 using Scalar.AspNetCore;
 
 namespace SplitTheBill.Api.Extensions;
@@ -13,4 +14,38 @@ internal static IEndpointRouteBuilder MapOpenApiEndpoints(this IEndpointRouteBui
 
         return app;
     }
+
+    public static OpenApiOptions CustomSchemaIds(this OpenApiOptions config,
+        Func<Type, string?> typeSchemaTransformer,
+        bool includeValueTypes = false)
+        => config.AddSchemaTransformer((schema, context, _) =>
+        {
+            // Skip value types and strings
+            if (!includeValueTypes &&
+                (context.JsonTypeInfo.Type.IsValueType ||
+                 context.JsonTypeInfo.Type == typeof(String) ||
+                 context.JsonTypeInfo.Type == typeof(string)))
+            {
+                return Task.CompletedTask;
+            }
+
+            // Skip if the schema ID is not already set because we don't want to decorate the schema multiple times
+            if (schema.Annotations == null || !schema.Annotations.TryGetValue("x-schema-id", out object? _))
+            {
+                return Task.CompletedTask;
+            }
+
+            // transform the typename based on the provided delegate
+            string? transformedTypeName = typeSchemaTransformer(context.JsonTypeInfo.Type);
+
+            // Scalar - decorate the models section
+            schema.Annotations["x-schema-id"] = transformedTypeName;
+
+            // Swagger and Scalar specific:
+            // for Scalar - decorate the endpoint section
+            // for Swagger - decorate the endpoint and model sections
+            schema.Title = transformedTypeName;
+
+            return Task.CompletedTask;
+        });
 }
\ No newline at end of file
diff --git a/api/src/3-presentation/Api/Program.cs b/api/src/3-presentation/Api/Program.cs
index be0e554..a1fc7f6 100644
--- a/api/src/3-presentation/Api/Program.cs
+++ b/api/src/3-presentation/Api/Program.cs
@@ -42,6 +42,8 @@
         // it reaches it
         .UseStatusCodePages();
 
+    app.UseCors();
+
     app.MapHealthChecks("/health");
     app.MapOpenApiEndpoints();
     app
diff --git a/api/src/3-presentation/Api/appsettings.Development.json b/api/src/3-presentation/Api/appsettings.Development.json
index bf521ce..6503b82 100644
--- a/api/src/3-presentation/Api/appsettings.Development.json
+++ b/api/src/3-presentation/Api/appsettings.Development.json
@@ -19,5 +19,11 @@
   },
   "ConnectionStrings": {
     "AppDbContext": ""
+  },
+  "Cors": {
+    "AllowCors": true,
+    "AllowedOrigins": [
+      "http://localhost:3000"
+    ]
   }
 }
diff --git a/api/src/3-presentation/Api/appsettings.json b/api/src/3-presentation/Api/appsettings.json
index 1ba873f..16a43a6 100644
--- a/api/src/3-presentation/Api/appsettings.json
+++ b/api/src/3-presentation/Api/appsettings.json
@@ -10,5 +10,9 @@
   },
   "ConnectionStrings": {
     "AppDbContext": ""
+  },
+  "Cors": {
+    "AllowCors": false,
+    "AllowedOrigins": []
   }
 }
diff --git a/frontend/app/components/app/ColorModeButton.vue b/frontend/app/components/app/ColorModeButton.vue
new file mode 100644
index 0000000..6746bb7
--- /dev/null
+++ b/frontend/app/components/app/ColorModeButton.vue
@@ -0,0 +1,23 @@
+<template>
+  <ClientOnly>
+    <UButton
+      v-if="!isUnknown"
+      :icon="icon"
+      aria-label="Toggle color mode"
+      color="neutral"
+      variant="ghost"
+      @click="toggleColorMode"
+    />
+  </ClientOnly>
+</template>
+
+<script setup lang="ts">
+const colorMode = useColorMode();
+const isUnknown = computed(() => colorMode.value === 'unknown');
+const isDark = computed(() => colorMode.value === 'dark');
+const icon = computed(() => (isDark.value ? 'i-mynaui-sun' : 'i-mynaui-moon'));
+
+const toggleColorMode = () => {
+  colorMode.value = isDark.value ? 'light' : 'dark';
+};
+</script>
diff --git a/frontend/app/components/app/footer.vue b/frontend/app/components/app/footer.vue
index eb9b144..8e67907 100644
--- a/frontend/app/components/app/footer.vue
+++ b/frontend/app/components/app/footer.vue
@@ -5,14 +5,7 @@
     </div>
 
     <div class="right">
-      <UButton
-        :icon="colorModeIcon"
-        aria-label="Toggle color mode"
-        color="neutral"
-        variant="ghost"
-        @click="toggleColorMode"
-      />
-
+      <AppColorModeButton />
       <UButton
         to="https://github.com/butskristof/split-the-bill"
         target="_blank"
@@ -25,20 +18,6 @@
   </footer>
 </template>
 
-<script setup lang="ts">
-//#region color mode
-
-const colorMode = useColorMode();
-const colorModeIcon = computed(() =>
-  colorMode.value === 'dark' ? 'i-mynaui-sun' : 'i-mynaui-moon',
-);
-const toggleColorMode = () => {
-  colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
-};
-
-//#endregion
-</script>
-
 <style scoped>
 footer {
   display: flex;
diff --git a/frontend/app/components/groups/groups-list.vue b/frontend/app/components/groups/groups-list.vue
new file mode 100644
index 0000000..6f64ba2
--- /dev/null
+++ b/frontend/app/components/groups/groups-list.vue
@@ -0,0 +1,5 @@
+<template>
+  <div class="groups">
+    <h2>Groups</h2>
+  </div>
+</template>
diff --git a/frontend/app/components/members/member-list-item.vue b/frontend/app/components/members/member-list-item.vue
new file mode 100644
index 0000000..b160aab
--- /dev/null
+++ b/frontend/app/components/members/member-list-item.vue
@@ -0,0 +1,3 @@
+<template>
+  <li>name (id)</li>
+</template>
diff --git a/frontend/app/components/members/members-list.vue b/frontend/app/components/members/members-list.vue
new file mode 100644
index 0000000..5bf26c7
--- /dev/null
+++ b/frontend/app/components/members/members-list.vue
@@ -0,0 +1,17 @@
+<template>
+  <div class="members">
+    <h2>Members</h2>
+    <p v-if="isPending">Loading...</p>
+    <p v-else-if="error">{{ error }}</p>
+    <div v-else>
+      <pre>{{ JSON.stringify(data, null, 2) }}</pre>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { getMembers } from '#shared/api-clients/split-the-bill-api/api-client';
+
+const { data, error, status } = useLazyAsyncData('members', getMembers);
+const isPending = computed(() => status.value === 'pending');
+</script>
diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue
index 872ebff..2e93a16 100644
--- a/frontend/app/pages/index.vue
+++ b/frontend/app/pages/index.vue
@@ -1,8 +1,11 @@
 <template>
   <div>
     <h1>Hello, world!</h1>
+    <UButton to="/other">To other page</UButton>
+    <MembersList />
+    <GroupsList />
     <div
-      v-if="true"
+      v-if="false"
       class="content"
     >
       <p>Hello, world!</p>
@@ -59,3 +62,4 @@
     </div>
   </div>
 </template>
+<script setup lang="ts"></script>
diff --git a/frontend/app/pages/other.vue b/frontend/app/pages/other.vue
new file mode 100644
index 0000000..d01847c
--- /dev/null
+++ b/frontend/app/pages/other.vue
@@ -0,0 +1,6 @@
+<template>
+  <div class="other-page">
+    <h1>Other page</h1>
+    <UButton to="/">Back to home</UButton>
+  </div>
+</template>
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 79cdb59..233438a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
       "dependencies": {
         "@nuxt/ui": "^3.0.2",
         "nuxt": "^3.16.1",
+        "openapi-fetch": "^0.13.5",
         "vue": "^3.5.13",
         "vue-router": "^4.5.0"
       },
@@ -16,6 +17,7 @@
         "@nuxt/eslint": "^1.3.0",
         "eslint": "^9.23.0",
         "eslint-plugin-vue-scoped-css": "^2.9.0",
+        "openapi-typescript": "^7.6.1",
         "prettier": "^3.5.3",
         "typescript": "^5.8.2",
         "vue-eslint-parser": "^10.1.1"
@@ -2807,6 +2809,72 @@
         "node": ">=18"
       }
     },
+    "node_modules/@redocly/ajv": {
+      "version": "8.11.2",
+      "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
+      "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js-replace": "^1.0.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@redocly/config": {
+      "version": "0.22.2",
+      "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz",
+      "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@redocly/openapi-core": {
+      "version": "1.34.1",
+      "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.1.tgz",
+      "integrity": "sha512-KI1QOGvDk6oREbTu0JORxZX1NBxraXUbXczv0LYDs9EPp06coq874hQORqSHGEUV/DX2A6gjv4Ax33g/LFJBww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@redocly/ajv": "^8.11.2",
+        "@redocly/config": "^0.22.0",
+        "colorette": "^1.2.0",
+        "https-proxy-agent": "^7.0.5",
+        "js-levenshtein": "^1.1.6",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^5.0.1",
+        "pluralize": "^8.0.0",
+        "yaml-ast-parser": "0.0.43"
+      },
+      "engines": {
+        "node": ">=18.17.0",
+        "npm": ">=9.5.0"
+      }
+    },
+    "node_modules/@redocly/openapi-core/node_modules/minimatch": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/@rollup/plugin-alias": {
       "version": "5.1.1",
       "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz",
@@ -4564,6 +4632,16 @@
         "url": "https://github.com/sponsors/epoberezkin"
       }
     },
+    "node_modules/ansi-colors": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+      "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/ansi-regex": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@@ -5129,6 +5207,13 @@
         "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
+    "node_modules/change-case": {
+      "version": "5.4.4",
+      "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
+      "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
+      "devOptional": true,
+      "license": "MIT"
+    },
     "node_modules/chokidar": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -5332,6 +5417,13 @@
       "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
       "license": "MIT"
     },
+    "node_modules/colorette": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+      "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/colortranslator": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/colortranslator/-/colortranslator-4.1.0.tgz",
@@ -7991,6 +8083,16 @@
         "jiti": "lib/jiti-cli.mjs"
       }
     },
+    "node_modules/js-levenshtein": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
+      "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -9330,6 +9432,55 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/openapi-fetch": {
+      "version": "0.13.5",
+      "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.5.tgz",
+      "integrity": "sha512-AQK8T9GSKFREFlN1DBXTYsLjs7YV2tZcJ7zUWxbjMoQmj8dDSFRrzhLCbHPZWA1TMV3vACqfCxLEZcwf2wxV6Q==",
+      "license": "MIT",
+      "dependencies": {
+        "openapi-typescript-helpers": "^0.0.15"
+      }
+    },
+    "node_modules/openapi-typescript": {
+      "version": "7.6.1",
+      "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.6.1.tgz",
+      "integrity": "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@redocly/openapi-core": "^1.28.0",
+        "ansi-colors": "^4.1.3",
+        "change-case": "^5.4.4",
+        "parse-json": "^8.1.0",
+        "supports-color": "^9.4.0",
+        "yargs-parser": "^21.1.1"
+      },
+      "bin": {
+        "openapi-typescript": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "typescript": "^5.x"
+      }
+    },
+    "node_modules/openapi-typescript-helpers": {
+      "version": "0.0.15",
+      "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
+      "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
+      "license": "MIT"
+    },
+    "node_modules/openapi-typescript/node_modules/supports-color": {
+      "version": "9.4.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz",
+      "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10581,6 +10732,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/resolve": {
       "version": "1.22.10",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -12427,6 +12588,13 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/uri-js-replace": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
+      "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -13148,6 +13316,13 @@
         "node": ">= 14"
       }
     },
+    "node_modules/yaml-ast-parser": {
+      "version": "0.0.43",
+      "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
+      "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
     "node_modules/yargs": {
       "version": "17.7.2",
       "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 825c2dd..5e5d944 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,11 +11,13 @@
     "lint:check": "eslint .",
     "lint:fix": "eslint . --fix",
     "format:check": "prettier --check .",
-    "format:fix": "prettier --write ."
+    "format:fix": "prettier --write .",
+    "test:ts": "tsc --noEmit"
   },
   "dependencies": {
     "@nuxt/ui": "^3.0.2",
     "nuxt": "^3.16.1",
+    "openapi-fetch": "^0.13.5",
     "vue": "^3.5.13",
     "vue-router": "^4.5.0"
   },
@@ -23,6 +25,7 @@
     "@nuxt/eslint": "^1.3.0",
     "eslint": "^9.23.0",
     "eslint-plugin-vue-scoped-css": "^2.9.0",
+    "openapi-typescript": "^7.6.1",
     "prettier": "^3.5.3",
     "typescript": "^5.8.2",
     "vue-eslint-parser": "^10.1.1"
diff --git a/frontend/shared/api-clients/split-the-bill-api/api-client.ts b/frontend/shared/api-clients/split-the-bill-api/api-client.ts
new file mode 100644
index 0000000..7967b46
--- /dev/null
+++ b/frontend/shared/api-clients/split-the-bill-api/api-client.ts
@@ -0,0 +1,32 @@
+import createClient from 'openapi-fetch';
+import type { components, paths } from '~~/shared/api-clients/split-the-bill-api/spec';
+
+const createSplitTheBillApiClient = () => createClient<paths>({ baseUrl: 'http://localhost:5222' });
+
+export const getMembers = () =>
+  createSplitTheBillApiClient()
+    .GET('/Members')
+    .then((r) => r.data);
+
+export const getGroups = () => createSplitTheBillApiClient().GET('/Groups');
+
+export const getGroup = (id: string) =>
+  createSplitTheBillApiClient().GET('/Groups/{id}', {
+    params: { path: { id } },
+  });
+
+export const postGroup = (request: components['schemas']['CreateGroup.Request']) =>
+  createSplitTheBillApiClient().POST('/Groups', {
+    body: request,
+  });
+
+export const putGroup = (id: string, request: components['schemas']['UpdateGroup.Request']) =>
+  createSplitTheBillApiClient().PUT('/Groups/{id}', {
+    params: { path: { id } },
+    body: request,
+  });
+
+export const deleteGroup = (id: string) =>
+  createSplitTheBillApiClient().DELETE('/Groups/{id}', {
+    params: { path: { id } },
+  });
diff --git a/frontend/shared/api-clients/split-the-bill-api/spec.d.ts b/frontend/shared/api-clients/split-the-bill-api/spec.d.ts
new file mode 100644
index 0000000..d3a8110
--- /dev/null
+++ b/frontend/shared/api-clients/split-the-bill-api/spec.d.ts
@@ -0,0 +1,768 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+    "/Members": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get: operations["GetMembers"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/Groups": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get: operations["GetGroups"];
+        put?: never;
+        post: operations["CreateGroup"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/Groups/{id}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get: operations["GetGroup"];
+        put: operations["UpdateGroup"];
+        post?: never;
+        delete: operations["DeleteGroup"];
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/Groups/{groupId}/expenses": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        post: operations["CreateExpense"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/Groups/{groupId}/expenses/{expenseId}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put: operations["UpdateExpense"];
+        post?: never;
+        delete: operations["DeleteExpense"];
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/Groups/{groupId}/payments": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        post: operations["CreatePayment"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/Groups/{groupId}/payments/{paymentId}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put: operations["UpdatePayment"];
+        post?: never;
+        delete: operations["DeletePayment"];
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+}
+export type webhooks = Record<string, never>;
+export interface components {
+    schemas: {
+        /** CreateExpense.Request */
+        "CreateExpense.Request": {
+            /** Format: uuid */
+            groupId?: string | null;
+            description?: string | null;
+            /** Format: uuid */
+            paidByMemberId?: string | null;
+            /** Format: double */
+            amount?: number | null;
+            splitType?: components["schemas"]["NullableOfExpenseSplitType"];
+            participants?: components["schemas"]["CreateExpense.Request.Participant"][];
+        };
+        /** CreateExpense.Request.Participant */
+        "CreateExpense.Request.Participant": {
+            /** Format: uuid */
+            memberId?: string | null;
+            /** Format: int32 */
+            percentualShare?: number | null;
+            /** Format: double */
+            exactShare?: number | null;
+        };
+        /** CreateGroup.Request */
+        "CreateGroup.Request": {
+            name?: string | null;
+        };
+        /** CreateGroup.Response */
+        "CreateGroup.Response": {
+            /** Format: uuid */
+            id: string;
+        };
+        /** CreatePayment.Request */
+        "CreatePayment.Request": {
+            /** Format: uuid */
+            groupId?: string | null;
+            /** Format: uuid */
+            sendingMemberId?: string | null;
+            /** Format: uuid */
+            receivingMemberId?: string | null;
+            /** Format: double */
+            amount?: number | null;
+        };
+        ExpenseSplitType: number;
+        /** GetGroups.Response */
+        "GetGroups.Response": {
+            groups: components["schemas"]["GetGroups.Response.GroupDto"][];
+        };
+        /** GetGroups.Response.GroupDto */
+        "GetGroups.Response.GroupDto": {
+            /** Format: uuid */
+            id: string;
+            name: string;
+        };
+        /** GetMembers.Response */
+        "GetMembers.Response": {
+            members: components["schemas"]["GetMembers.Response.MemberDto"][];
+        };
+        /** GetMembers.Response.MemberDto */
+        "GetMembers.Response.MemberDto": {
+            /** Format: uuid */
+            id: string;
+            name: string;
+        };
+        /** GroupDto */
+        GroupDto: {
+            /** Format: uuid */
+            id?: string;
+            name?: string | null;
+            members?: components["schemas"]["GroupDto.MemberDto"][] | null;
+            expenses?: components["schemas"]["GroupDto.ExpenseDto"][] | null;
+            payments?: components["schemas"]["GroupDto.PaymentDto"][] | null;
+            /** Format: double */
+            totalExpenseAmount?: number;
+            /** Format: double */
+            totalPaymentAmount?: number;
+            /** Format: double */
+            totalBalance?: number;
+        };
+        /** GroupDto.ExpenseDto */
+        "GroupDto.ExpenseDto": {
+            /** Format: uuid */
+            id?: string;
+            description?: string;
+            /** Format: double */
+            amount?: number;
+            splitType?: components["schemas"]["ExpenseSplitType"];
+            participants?: components["schemas"]["GroupDto.ExpenseDto.ExpenseParticipantDto"][];
+            /** Format: uuid */
+            paidByMemberId?: string;
+        };
+        /** GroupDto.ExpenseDto.ExpenseParticipantDto */
+        "GroupDto.ExpenseDto.ExpenseParticipantDto": {
+            /** Format: uuid */
+            memberId?: string;
+            /** Format: int32 */
+            percentualShare?: number | null;
+            /** Format: double */
+            exactShare?: number | null;
+        };
+        /** GroupDto.MemberDto */
+        "GroupDto.MemberDto": {
+            /** Format: uuid */
+            id?: string;
+            name?: string | null;
+            /** Format: double */
+            totalExpenseAmount?: number;
+            /** Format: double */
+            totalExpensePaidAmount?: number;
+            /** Format: double */
+            totalExpenseAmountPaidByOtherMembers?: number;
+            /** Format: double */
+            totalPaymentReceivedAmount?: number;
+            /** Format: double */
+            totalPaymentSentAmount?: number;
+            /** Format: double */
+            totalAmountOwed?: number;
+            /** Format: double */
+            totalAmountOwedToOtherMembers?: number;
+            /** Format: double */
+            totalBalance?: number;
+        };
+        /** GroupDto.PaymentDto */
+        "GroupDto.PaymentDto": {
+            /** Format: uuid */
+            id?: string;
+            /** Format: uuid */
+            sendingMemberId?: string;
+            /** Format: uuid */
+            receivingMemberId?: string;
+            /** Format: double */
+            amount?: number;
+        };
+        /** HttpValidationProblemDetails */
+        HttpValidationProblemDetails: {
+            type?: string | null;
+            title?: string | null;
+            /** Format: int32 */
+            status?: number | null;
+            detail?: string | null;
+            instance?: string | null;
+            errors?: {
+                [key: string]: string[];
+            };
+        };
+        NullableOfExpenseSplitType: number | null;
+        /** ProblemDetails */
+        ProblemDetails: {
+            type?: string | null;
+            title?: string | null;
+            /** Format: int32 */
+            status?: number | null;
+            detail?: string | null;
+            instance?: string | null;
+        };
+        /** UpdateExpense.Request */
+        "UpdateExpense.Request": {
+            /** Format: uuid */
+            groupId?: string | null;
+            /** Format: uuid */
+            expenseId?: string | null;
+            description?: string | null;
+            /** Format: uuid */
+            paidByMemberId?: string | null;
+            /** Format: double */
+            amount?: number | null;
+            splitType?: components["schemas"]["NullableOfExpenseSplitType"];
+            participants?: components["schemas"]["UpdateExpense.Request.Participant"][];
+        };
+        /** UpdateExpense.Request.Participant */
+        "UpdateExpense.Request.Participant": {
+            /** Format: uuid */
+            memberId?: string | null;
+            /** Format: int32 */
+            percentualShare?: number | null;
+            /** Format: double */
+            exactShare?: number | null;
+        };
+        /** UpdateGroup.Request */
+        "UpdateGroup.Request": {
+            /** Format: uuid */
+            id?: string;
+            name?: string | null;
+        };
+        /** UpdatePayment.Request */
+        "UpdatePayment.Request": {
+            /** Format: uuid */
+            groupId?: string | null;
+            /** Format: uuid */
+            paymentId?: string | null;
+            /** Format: uuid */
+            sendingMemberId?: string | null;
+            /** Format: uuid */
+            receivingMemberId?: string | null;
+            /** Format: double */
+            amount?: number | null;
+        };
+    };
+    responses: never;
+    parameters: never;
+    requestBodies: never;
+    headers: never;
+    pathItems: never;
+}
+export type $defs = Record<string, never>;
+export interface operations {
+    GetMembers: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description OK */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": components["schemas"]["GetMembers.Response"];
+                };
+            };
+        };
+    };
+    GetGroups: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description OK */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": components["schemas"]["GetGroups.Response"];
+                };
+            };
+        };
+    };
+    CreateGroup: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["CreateGroup.Request"];
+            };
+        };
+        responses: {
+            /** @description Created */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": components["schemas"]["CreateGroup.Response"];
+                };
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+        };
+    };
+    GetGroup: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                id: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description OK */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": components["schemas"]["GroupDto"];
+                };
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    UpdateGroup: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                id: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["UpdateGroup.Request"];
+            };
+        };
+        responses: {
+            /** @description No Content */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    DeleteGroup: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                id: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description No Content */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    CreateExpense: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                groupId: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["CreateExpense.Request"];
+            };
+        };
+        responses: {
+            /** @description Created */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    UpdateExpense: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                groupId: string;
+                expenseId: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["UpdateExpense.Request"];
+            };
+        };
+        responses: {
+            /** @description No Content */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    DeleteExpense: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                groupId: string;
+                expenseId: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description No Content */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    CreatePayment: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                groupId: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["CreatePayment.Request"];
+            };
+        };
+        responses: {
+            /** @description Created */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    UpdatePayment: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                groupId: string;
+                paymentId: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["UpdatePayment.Request"];
+            };
+        };
+        responses: {
+            /** @description No Content */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    DeletePayment: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                groupId: string;
+                paymentId: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description No Content */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description Bad Request */
+            400: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
+                };
+            };
+            /** @description Not Found */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index a746f2a..2fe178f 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,4 +1,9 @@
 {
   // https://nuxt.com/docs/guide/concepts/typescript
-  "extends": "./.nuxt/tsconfig.json"
+  "extends": "./.nuxt/tsconfig.json",
+  "compilerOptions": {
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "noUncheckedIndexedAccess": true
+  }
 }
diff --git a/scripts/generate-openapi-ts.sh b/scripts/generate-openapi-ts.sh
new file mode 100755
index 0000000..e0da608
--- /dev/null
+++ b/scripts/generate-openapi-ts.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+
+# Description:
+#
+# Usage:
+#   ./generate-openapi-ts.sh [-i <openapi-spec-url>] [-c <frontend-dir>] [-o <output-file>]
+#
+# Options:
+#   -i <openapi-spec-url>  URL of the OpenAPI spec (default: http://localhost:5222/openapi/spec.json)
+#   -c <frontend-dir>     Path to the frontend directory (default: ../frontend)
+#   -o <output-file>      Path to the output file (default: ./shared/api-clients/split-the-bill-api/spec.d.ts)
+#                           NOTE: this is relative to the frontend directory
+# 
+# Example:
+#   ./generate-openapi-ts.sh \
+#       -i http://localhost:5222/openapi/spec.json \
+#       -c ../frontend \
+#       -o ./shared/api-clients/split-the-bill-api/spec.d.ts
+#
+# Documentation:
+#   - https://openapi-ts.dev/introduction
+#   - https://openapi-ts.dev/openapi-fetch/
+#
+# Notes:
+#   - The script expects the frontend folder to have all Node.js dependencies installed (both normal and dev).
+#
+# Dependencies
+#   - Node.js
+#   - nvm preconfigured with the version specified in the frontend's .nvmrc file
+
+# Function to display usage information
+usage() {
+    echo "Usage: $0 [-i <openapi-spec-url>] [-c <frontend-dir>] [-o <output-file>]"
+    echo "  -i  OpenAPI spec URL (default: http://localhost:5222/openapi/spec.json)"
+    echo "  -c  Frontend directory (default: ../frontend)"
+    echo "  -o  Output file (relative to frontend directory, default: ./shared/api-clients/split-the-bill-api/spec.d.ts)"
+    exit 1
+}
+
+# Default values
+OPENAPI_SPEC_URL="http://localhost:5222/openapi/spec.json"
+FRONTEND_DIR="../frontend"
+OUTPUT_FILE="./shared/api-clients/split-the-bill-api/spec.d.ts"
+
+# Parse command line arguments
+while getopts "i:c:o:" opt; do
+    case ${opt} in
+        i)
+            OPENAPI_SPEC_URL=$OPTARG
+            ;;
+        c)
+            FRONTEND_DIR=$OPTARG
+            ;;
+        o)
+            OUTPUT_FILE=$OPTARG
+            ;;
+        *)
+            usage
+            ;;
+    esac
+done
+shift $((OPTIND - 1))
+
+echo "Generating TypeScript types from OpenAPI spec"
+echo "OpenAPI spec URL: $OPENAPI_SPEC_URL"
+echo "Frontend directory: $FRONTEND_DIR"
+echo "Output file: $OUTPUT_FILE"
+
+# Change directory to the frontend folder (sibling of the current project)
+pushd "$FRONTEND_DIR" > /dev/null || { echo "Frontend folder not found"; exit 1; }
+
+nvm use
+npx openapi-typescript "$OPENAPI_SPEC_URL" --output "$OUTPUT_FILE"
+
+popd > /dev/null
+echo "Generated TypeScript types from OpenAPI spec and saved to $OUTPUT_FILE"
\ No newline at end of file


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR sets up the API client integration on the frontend and adjusts backend API configuration (including CORS and OpenAPI schema customization) to support the overall application requirements. Key changes include:
- Adding new Vue pages and components for navigating and displaying members and groups.
- Replacing legacy DTOs with inline record definitions in application modules.
- Updating the API startup and dependency injection to incorporate CORS and custom OpenAPI schema IDs.

### Reviewed Changes

Copilot reviewed 20 out of 24 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| frontend/app/pages/other.vue | New page to display "Other" content with a navigation button. |
| frontend/app/pages/index.vue | Updates navigation with new buttons and component insertions; disables a content block. |
| frontend/app/components/members/members-list.vue | Adds a members list component that fetches member data from the API. |
| frontend/app/components/members/member-list-item.vue | New component for member list items. |
| frontend/app/components/groups/groups-list.vue | New groups list component. |
| frontend/app/components/app/footer.vue | Replaces inline color mode toggling with a dedicated ColorModeButton component. |
| frontend/app/components/app/ColorModeButton.vue | New component encapsulating color mode toggle functionality. |
| api/src/3-presentation/Api/Program.cs | Adds CORS middleware usage to the API pipeline. |
| api/src/3-presentation/Api/Extensions/OpenApiExtensions.cs | Introduces an extension to customize OpenAPI schema IDs. |
| api/src/3-presentation/Api/DependencyInjection.cs | Updates DI configuration to validate settings and configure CORS policies. |
| api/src/1-core/Application/Modules/Members/GetMembers.cs | Updates member DTO mapping to use the inline record type. |
| api/src/1-core/Application/Modules/Groups/GetGroups.cs | Updates group DTO mapping to use the inline record type. |
| api/src/1-core/Application/Common/Dto/MemberDto.cs | Removed legacy DTO definition for members. |
| api/src/1-core/Application/Common/Dto/GroupDto.cs | Removed legacy DTO definition for groups. |
| api/src/1-core/Application/Common/Configuration/ISettings.cs | Adds a settings interface for configuration validation. |
| api/src/1-core/Application/Common/Configuration/CorsSettings.cs | Adds a new configuration class for CORS settings. |
</details>


<details>
<summary>Files not reviewed (4)</summary>

* **api/src/3-presentation/Api/appsettings.Development.json**: Language not supported
* **api/src/3-presentation/Api/appsettings.json**: Language not supported
* **frontend/package-lock.json**: Language not supported
* **frontend/package.json**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**frontend/app/pages/index.vue:8**
* [nitpick] The v-if condition has been changed to false, which disables rendering of this block. Confirm if this is intentional or update the condition accordingly.
```
v-if="false"
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T22:27:59Z
user: login: butskristof
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-01T22:31:10Z

Review comments:
diff_hunk: @@ -78,6 +63,22 @@ internal static IServiceCollection AddApi(this IServiceCollection services)
         services
             .AddHealthChecks();
 
+        // TODO remove after BFF/Proxy setup
+        services.AddCors(options =>
+        {
+            using var serviceProvider = services.BuildServiceProvider();
+            var settings = serviceProvider
+                .GetRequiredService<IOptions<CorsSettings>>()
+                .Value;
+            if (!settings.AllowCors) return;
+
+            options.AddDefaultPolicy(policy => policy
+                .WithOrigins(settings.AllowedOrigins)
+                .AllowAnyHeader()
+                .AllowAnyMethod()
+            );
+        });
+
path: api/src/3-presentation/Api/DependencyInjection.cs
user: login: Copilot
body: Using BuildServiceProvider inside the DI configuration can lead to unintended service lifetimes and potential issues in production. Consider refactoring to avoid creating a temporary service provider.
```suggestion
        var serviceProvider = services.BuildServiceProvider();
        var settings = serviceProvider.GetRequiredService<IOptions<CorsSettings>>().Value;
        if (settings.AllowCors)
        {
            services.AddCors(options =>
            {
                options.AddDefaultPolicy(policy => policy
                    .WithOrigins(settings.AllowedOrigins)
                    .AllowAnyHeader()
                    .AllowAnyMethod()
                );
            });
        }
```
created_at: 2025-04-01T22:27:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,6 +63,22 @@ internal static IServiceCollection AddApi(this IServiceCollection services)
         services
             .AddHealthChecks();
 
+        // TODO remove after BFF/Proxy setup
+        services.AddCors(options =>
+        {
+            using var serviceProvider = services.BuildServiceProvider();
+            var settings = serviceProvider
+                .GetRequiredService<IOptions<CorsSettings>>()
+                .Value;
+            if (!settings.AllowCors) return;
+
+            options.AddDefaultPolicy(policy => policy
+                .WithOrigins(settings.AllowedOrigins)
+                .AllowAnyHeader()
+                .AllowAnyMethod()
+            );
+        });
+
path: api/src/3-presentation/Api/DependencyInjection.cs
user: login: butskristof
body: This is intentional, the suggestion does not fix the issue and the code should be factored out before production release. 
created_at: 2025-04-01T22:31:10Z
author_association: OWNER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-01T22:28:42Z
body: ## Test Results
  3 files    3 suites   56s ⏱️
318 tests 317 ✅ 1 💤 0 ❌
347 runs  346 ✅ 1 💤 0 ❌

Results for commit 6830eded.

[test-results]:data:application/gzip;base64,H4sIAOBo7GcC/03MSwrDIBSF4a0Exx1ofaabKfZ6BWkSi49R6N4rkpoM/+/A2YkPC2bymPhtIrmGMsLVZEuIW0upWrel9I2Zfz1zBeikT3qHTyM2wNuwNKADMKWYDkl1659CHzEuhRpyPva+HPa+/kFc11BaEGU4RYfOz1Tg3QEDLTXOXmnJpfT4kkCtBkO+PxTY9YkEAQAA

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-01T22:29:46Z
body: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-96%25-success?style=flat)

Package | Line Rate | Branch Rate | Complexity | Health
-------- | --------- | ----------- | ---------- | ------
SplitTheBill.Application.dll | 97% | 100% | NaN | ✔
SplitTheBill.Application.Tests.Shared.dll | 89% | 100% | NaN | ✔
SplitTheBill.Domain.dll | 98% | 100% | NaN | ✔
SplitTheBill.Persistence.dll | 96% | 100% | NaN | ✔
**Summary** | **96%** (2488 / 2592) | **100%** (0 / 0) | **NaN** | ✔

<!-- Sticky Pull Request Comment -->
author_association: NONE