Merge pull request #35 from StewKI/feature-client-with-working-auth

Feature client with working auth
This commit is contained in:
Veljko
2026-02-15 23:13:39 +01:00
committed by GitHub
12 changed files with 386 additions and 74 deletions

View File

@@ -0,0 +1,5 @@
using AipsCore.Application.Abstract.Query;
namespace AipsCore.Application.Models.User.Query.GetMe;
public record GetMeQuery : IQuery<GetMeQueryDto>;

View File

@@ -0,0 +1,3 @@
namespace AipsCore.Application.Models.User.Query.GetMe;
public record GetMeQueryDto(string UserName);

View File

@@ -0,0 +1,37 @@
using AipsCore.Application.Abstract.Query;
using AipsCore.Application.Abstract.UserContext;
using AipsCore.Domain.Common.Validation;
using AipsCore.Domain.Models.User.Validation;
using AipsCore.Domain.Models.User.ValueObjects;
using AipsCore.Infrastructure.Persistence.Db;
using Microsoft.EntityFrameworkCore;
namespace AipsCore.Application.Models.User.Query.GetMe;
public class GetMeQueryHandler : IQueryHandler<GetMeQuery, GetMeQueryDto>
{
private readonly AipsDbContext _context;
private readonly IUserContext _userContext;
public GetMeQueryHandler(AipsDbContext context, IUserContext userContext)
{
_context = context;
_userContext = userContext;
}
public async Task<GetMeQueryDto> Handle(GetMeQuery query, CancellationToken cancellationToken = default)
{
var userId = _userContext.GetCurrentUserId();
var result = await _context.Users
.Where(u => u.Id.ToString() == userId.IdValue)
.FirstOrDefaultAsync(cancellationToken);
if (result is null)
{
throw new ValidationException(UserErrors.NotFound(new UserId(userId.IdValue)));
}
return new GetMeQueryDto(result.UserName!);
}
}

View File

@@ -1,21 +1,21 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Common.Authentication.Dtos;
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Common.Authentication;
using AipsCore.Application.Common.Message.TestMessage;
using AipsCore.Application.Models.User.Command.LogIn;
using AipsCore.Application.Models.User.Command.LogOut;
using AipsCore.Application.Models.User.Command.LogOutAll;
using AipsCore.Application.Models.User.Command.RefreshLogIn;
using AipsCore.Application.Models.User.Command.SignUp;
using AipsCore.Application.Models.User.Query.GetUser;
using AipsCore.Application.Models.User.Query.GetMe;
using AipsCore.Infrastructure.Persistence.User;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace AipsWebApi.Controllers;
[ApiController]
[Route("[controller]")]
[Route("/api/[controller]")]
public class UserController : ControllerBase
{
private readonly IDispatcher _dispatcher;
@@ -72,4 +72,12 @@ public class UserController : ControllerBase
var test = new TestMessage("ovo je test poruka");
await publisher.PublishAsync(test);
}
[Authorize]
[HttpGet("me")]
public async Task<ActionResult<GetMeQueryDto>> GetMe(CancellationToken cancellationToken)
{
var result = await _dispatcher.Execute(new GetMeQuery(), cancellationToken);
return result;
}
}

View File

@@ -14,6 +14,18 @@ builder.Services.AddOpenApi();
builder.Services.AddAips(builder.Configuration);
builder.Services.AddCors(options =>
{
options.AddPolicy("frontend", policy =>
{
policy
.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
var app = builder.Build();
await app.Services.InitializeInfrastructureAsync();
@@ -24,6 +36,8 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseCors("frontend");
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseAuthentication();

View File

@@ -0,0 +1,55 @@
<template>
<div class="auth-status">
<template v-if="isLoading">
<span class="loading">Loading...</span>
</template>
<template v-else-if="isAuthenticated">
<span class="username">{{ user?.username ?? user?.email }}</span>
<button @click="logout" class="logout">Logout</button>
</template>
<template v-else>
<slot>
<!-- Default: show nothing or provide links in parent -->
</slot>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const store = useAuthStore()
const user = computed(() => store.user)
const isAuthenticated = computed(() => store.isAuthenticated)
const isLoading = computed(() => store.isLoading)
function logout() {
store.logout()
}
onMounted(() => {
// ensure store is hydrated; safe to call multiple times
void store.initialize()
})
</script>
<style scoped>
.auth-status {
display: flex;
align-items: center;
gap: 8px;
}
.username {
font-weight: 600;
}
.logout {
padding: 4px 8px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
}
.loading {
color: #666;
}
</style>

View File

@@ -13,7 +13,7 @@ const pinia = createPinia()
app.use(pinia)
const auth = useAuthStore()
auth.initialize()
await auth.initialize()
app.use(router)

View File

@@ -1,37 +1,90 @@
import { useAuthStore } from '@/stores/auth'
// A small fetch-based HTTP client with automatic token attach and refresh-on-401.
// This avoids circular imports by reading/writing tokens directly from localStorage.
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api'
const ACCESS_TOKEN = 'auth_token'
const REFRESH_TOKEN = 'refresh_token'
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
async function parseJsonOrThrow(res: Response) {
const text = await res.text()
try {
return text ? JSON.parse(text) : undefined
} catch (e) {
// non-JSON response
return text
}
}
if (auth.token) {
headers['Authorization'] = `Bearer ${auth.token}`
}
async function refreshTokens(): Promise<{ accessToken?: string; refreshToken?: string } | null> {
const refreshToken = localStorage.getItem(REFRESH_TOKEN)
if (!refreshToken) return null
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
const res = await fetch('/api/User/refresh-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `Request failed: ${res.status}`)
if (!res.ok) return null
const data = await parseJsonOrThrow(res)
const accessToken = data?.accessToken ?? data?.token ?? null
const newRefresh = data?.refreshToken ?? null
if (accessToken) {
localStorage.setItem(ACCESS_TOKEN, accessToken)
}
if (newRefresh) {
localStorage.setItem(REFRESH_TOKEN, newRefresh)
}
return { accessToken, refreshToken: newRefresh }
}
if (res.status === 204) return undefined as T
async function request<T = any>(method: string, url: string, body?: any, attemptRefresh = true): Promise<T> {
const headers: Record<string, string> = {}
headers['Content-Type'] = 'application/json'
return res.json() as Promise<T>
const token = localStorage.getItem(ACCESS_TOKEN)
if (token) headers['Authorization'] = `Bearer ${token}`
const payload = body ?? {}
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(payload) : null,
})
if (res.status === 401 && attemptRefresh) {
// try refreshing tokens once
const refreshed = await refreshTokens()
if (refreshed && refreshed.accessToken) {
// retry original request with new token
const retryHeaders: Record<string, string> = {}
if (body !== undefined && body !== null) retryHeaders['Content-Type'] = 'application/json'
retryHeaders['Authorization'] = `Bearer ${refreshed.accessToken}`
const retryRes = await fetch(url, {
method,
headers: retryHeaders,
body: body !== undefined && body !== null ? JSON.stringify(body) : undefined,
})
if (!retryRes.ok) {
const errorBody = await parseJsonOrThrow(retryRes)
throw Object.assign(new Error(retryRes.statusText || 'Request failed'), { status: retryRes.status, body: errorBody })
}
return await parseJsonOrThrow(retryRes)
}
}
if (!res.ok) {
const errorBody = await parseJsonOrThrow(res)
throw Object.assign(new Error(res.statusText || 'Request failed'), { status: res.status, body: errorBody })
}
return await parseJsonOrThrow(res)
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
get: <T = any>(url: string) => request<T>('GET', url),
post: <T = any>(url: string, body?: any) => request<T>('POST', url, body),
put: <T = any>(url: string, body?: any) => request<T>('PUT', url, body),
delete: <T = any>(url: string, body?: any) => request<T>('DELETE', url, body),
}

View File

@@ -1,26 +1,41 @@
import type { AuthResponse, LoginCredentials, SignupCredentials, User } from '@/types'
import { api } from './api'
// TODO: Wire up to real API endpoints via `api` helper
// import { api } from './api'
function normalizeAuthResponse(raw: any): AuthResponse {
const accessToken = raw?.accessToken ?? raw?.AccessToken ?? raw?.token ?? raw?.Token ?? raw?.access_token ?? null
const refreshToken = raw?.refreshToken ?? raw?.RefreshToken ?? raw?.refresh_token ?? null
return { accessToken, refreshToken }
}
export const authService = {
async login(_credentials: LoginCredentials): Promise<AuthResponse> {
// TODO: return api.post<AuthResponse>('/auth/login', credentials)
throw new Error('Not implemented')
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const raw = await api.post<any>('/api/User/login', credentials)
return normalizeAuthResponse(raw)
},
async signup(_credentials: SignupCredentials): Promise<AuthResponse> {
// TODO: return api.post<AuthResponse>('/auth/signup', credentials)
throw new Error('Not implemented')
async refreshLogin(refreshToken: string): Promise<AuthResponse> {
const raw = await api.post<any>('/api/User/refresh-login', { refreshToken })
return normalizeAuthResponse(raw)
},
async logout(): Promise<void> {
// TODO: return api.post<void>('/auth/logout')
throw new Error('Not implemented')
async signup(credentials: SignupCredentials): Promise<AuthResponse> {
const raw = await api.post<any>('/api/User/signup', credentials)
return normalizeAuthResponse(raw)
},
async getCurrentUser(): Promise<User> {
// TODO: return api.get<User>('/auth/me')
throw new Error('Not implemented')
async logout(refreshToken: string): Promise<void> {
return api.delete<void>('/api/User/logout', {refreshToken: refreshToken})
},
async logoutAll(): Promise<void> {
return api.delete<void>('/api/User/logout-all')
},
async getMe() : Promise<User> {
const raw = await api.get<any>('/api/User/me')
// backend User may have fields like userName / UserName and email / Email
const username = raw?.userName ?? raw?.UserName ?? raw?.username ?? raw?.name ?? ''
const email = raw?.email ?? raw?.Email ?? ''
return { username, email }
},
}

View File

@@ -1,53 +1,165 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { User, LoginCredentials, SignupCredentials } from '@/types'
import type { User, LoginCredentials, SignupCredentials, AuthResponse } from '@/types'
import { authService } from '@/services/authService'
const TOKEN_KEY = 'auth_token'
const ACCESS_TOKEN = 'auth_token'
const REFRESH_TOKEN = 'refresh_token'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const accessToken = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
function initialize() {
const saved = localStorage.getItem(TOKEN_KEY)
if (saved) {
token.value = saved
// TODO: call authService.getCurrentUser() to validate token & hydrate user
user.value = { username: 'User', email: 'user@example.com' }
// single-flight promise for refresh to avoid concurrent refresh requests
let refreshPromise: Promise<void> | null = null
function setTokens(access: string | null, refresh: string | null) {
accessToken.value = access
if (access) {
localStorage.setItem(ACCESS_TOKEN, access)
} else {
localStorage.removeItem(ACCESS_TOKEN)
}
if (refresh) {
localStorage.setItem(REFRESH_TOKEN, refresh)
} else if (refresh === null) {
// explicit null means remove
localStorage.removeItem(REFRESH_TOKEN)
}
}
async function tryRefresh(): Promise<void> {
// if a refresh is already in progress, return that promise
if (refreshPromise) return refreshPromise
const refreshToken = localStorage.getItem(REFRESH_TOKEN)
if (!refreshToken) {
// nothing to do
throw new Error('No refresh token')
}
refreshPromise = (async () => {
try {
const res = await authService.refreshLogin(refreshToken)
// API expected to return { accessToken, refreshToken }
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
} finally {
// clear so subsequent calls can create a new promise
refreshPromise = null
}
})()
return refreshPromise
}
async function initialize() {
isLoading.value = true
error.value = null
const saved = localStorage.getItem(ACCESS_TOKEN)
const savedRefresh = localStorage.getItem(REFRESH_TOKEN)
if (saved) {
accessToken.value = saved
try {
user.value = await authService.getMe()
isLoading.value = false
return
} catch (e) {
// token might be expired; try refresh if we have refresh token
if (savedRefresh) {
try {
await tryRefresh()
user.value = await authService.getMe()
isLoading.value = false
return
} catch (err) {
// refresh failed - fallthrough to clearing auth
console.warn('Token refresh failed during initialize', err)
}
}
}
}
// not authenticated or refresh failed
setTokens(null, null)
user.value = null
isLoading.value = false
}
async function login(credentials: LoginCredentials) {
// TODO: const res = await authService.login(credentials)
// Mock successful response for now
const res = {
user: { username: credentials.email.split('@')[0] ?? credentials.email, email: credentials.email },
token: 'mock-jwt-token',
isLoading.value = true
error.value = null
try {
const res: AuthResponse = await authService.login(credentials)
// expect AuthResponse to have accessToken and refreshToken
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
try {
user.value = await authService.getMe()
} catch (e) {
console.error('Logged in but failed to fetch user profile', e)
// keep tokens but clear user
user.value = null
}
} catch (e: any) {
error.value = e?.message ?? 'Login failed'
throw e
} finally {
isLoading.value = false
}
user.value = res.user
token.value = res.token
localStorage.setItem(TOKEN_KEY, res.token)
}
async function signup(credentials: SignupCredentials) {
// TODO: const res = await authService.signup(credentials)
// Mock successful response for now
const res = {
user: { username: credentials.username, email: credentials.email },
token: 'mock-jwt-token',
}
user.value = res.user
token.value = res.token
localStorage.setItem(TOKEN_KEY, res.token)
}
isLoading.value = true
error.value = null
function logout() {
try {
const res: AuthResponse = await authService.signup(credentials)
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
try {
user.value = await authService.getMe()
} catch (e) {
// keep tokens but no profile
user.value = null
token.value = null
localStorage.removeItem(TOKEN_KEY)
}
} catch (e: any) {
error.value = e?.message ?? 'Signup failed'
throw e
} finally {
isLoading.value = false
}
}
return { user, token, isAuthenticated, initialize, login, signup, logout }
async function logout(allDevices = false) {
isLoading.value = true
error.value = null
try {
if (allDevices) await authService.logoutAll()
else await authService.logout(localStorage.getItem(REFRESH_TOKEN)!)
} catch (e) {
// ignore network errors on logout
console.warn('Logout request failed', e)
} finally {
setTokens(null, null)
user.value = null
isLoading.value = false
}
}
return {
user,
accessToken,
isAuthenticated,
isLoading,
error,
initialize,
login,
signup,
logout,
}
})

View File

@@ -15,6 +15,6 @@ export interface SignupCredentials {
}
export interface AuthResponse {
user: User
token: string
accessToken: string
refreshToken: string
}

View File

@@ -4,17 +4,27 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5266',
changeOrigin: true
}
}
},
css: {
preprocessorOptions: {
scss: {