feat: fully backend-driven OIDC session flow (session cookie, not bearer token)
Replace the hybrid flow (frontend PKCE + POST /auth/session token exchange, access token in sessionStorage) with a classic backend-driven BFF: the browser only ever navigates to GET /api/v1/auth/login and is redirected straight to the IdP; PKCE verifier/state live server-side in Redis (SessionStoreService); GET /api/v1/auth/callback (now the registered IdP redirect URI, replacing the frontend's /auth/callback route, which is deleted) verifies the id_token, JIT-provisions the user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax cookie before redirecting into the app. No token material of any kind ever reaches the browser. OidcAuthGuard (per-request bearer JWT verification) is replaced by SessionAuthGuard (cookie -> Redis session lookup) across every controller that used it. cookie-parser is now wired into main.ts. Frontend AuthService shrinks to login()/logout()/ensureSessionChecked(); pkce.ts, auth.interceptor.ts, and the callback component/route are all removed as dead code under this model. New required env var: APP_BASE_URL (source of truth for the OIDC redirect_uri and the post-login redirect target). Verified end-to-end against the real API, Redis, and a mocked IdP: login redirect shape, callback cookie + redirect, state-replay rejection, /users/me 401<->200 around the cookie, and logout.
This commit is contained in:
90
backend/apps/api/src/auth/auth-login.controller.spec.ts
Normal file
90
backend/apps/api/src/auth/auth-login.controller.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AuthLoginController } from './auth-login.controller';
|
||||
|
||||
function fakeResponse() {
|
||||
return {
|
||||
redirect: jest.fn(),
|
||||
cookie: jest.fn(),
|
||||
clearCookie: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthLoginController', () => {
|
||||
const environment = { appBaseUrl: 'http://localhost:4200' };
|
||||
|
||||
it('GET /auth/login redirects to the authorization URL built by AuthFlowService', async () => {
|
||||
const authFlow = {
|
||||
buildAuthorizationRedirect: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ url: 'https://idp.example.test/oidc/auth?...' }),
|
||||
};
|
||||
const sessionStore = { deleteSession: jest.fn() };
|
||||
const controller = new AuthLoginController(
|
||||
environment as never,
|
||||
authFlow as never,
|
||||
sessionStore as never,
|
||||
);
|
||||
const res = fakeResponse();
|
||||
|
||||
await controller.login(res as never);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
'https://idp.example.test/oidc/auth?...',
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /auth/callback sets an httpOnly session cookie and redirects into the app', async () => {
|
||||
const authFlow = {
|
||||
handleCallback: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ sessionId: 'session-1', expiresIn: 3600 }),
|
||||
};
|
||||
const sessionStore = { deleteSession: jest.fn() };
|
||||
const controller = new AuthLoginController(
|
||||
environment as never,
|
||||
authFlow as never,
|
||||
sessionStore as never,
|
||||
);
|
||||
const res = fakeResponse();
|
||||
|
||||
await controller.callback('code-1', 'state-1', res as never);
|
||||
|
||||
expect(authFlow.handleCallback).toHaveBeenCalledWith('code-1', 'state-1');
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'travel_planner_session',
|
||||
'session-1',
|
||||
expect.objectContaining({
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 3600 * 1000,
|
||||
}),
|
||||
);
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:4200/trips');
|
||||
});
|
||||
|
||||
it('POST /auth/logout deletes the session and clears the cookie', async () => {
|
||||
const authFlow = {};
|
||||
const sessionStore = {
|
||||
deleteSession: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const controller = new AuthLoginController(
|
||||
environment as never,
|
||||
authFlow as never,
|
||||
sessionStore as never,
|
||||
);
|
||||
const res = fakeResponse();
|
||||
|
||||
await controller.logout(
|
||||
{ cookies: { travel_planner_session: 'session-1' } } as never,
|
||||
res as never,
|
||||
);
|
||||
|
||||
expect(sessionStore.deleteSession).toHaveBeenCalledWith('session-1');
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(
|
||||
'travel_planner_session',
|
||||
expect.objectContaining({ path: '/' }),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(204);
|
||||
});
|
||||
});
|
||||
64
backend/apps/api/src/auth/auth-login.controller.ts
Normal file
64
backend/apps/api/src/auth/auth-login.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { APP_ENVIRONMENT } from '../../../../libs/configuration/src';
|
||||
import type { AppEnvironment } from '../../../../libs/configuration/src';
|
||||
import {
|
||||
AuthFlowService,
|
||||
SessionStoreService,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from '../../../../libs/auth/src';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthLoginController {
|
||||
constructor(
|
||||
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
||||
private readonly authFlow: AuthFlowService,
|
||||
private readonly sessionStore: SessionStoreService,
|
||||
) {}
|
||||
|
||||
@Get('login')
|
||||
async login(@Res() res: Response): Promise<void> {
|
||||
const { url } = await this.authFlow.buildAuthorizationRedirect();
|
||||
res.redirect(url);
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
async callback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { sessionId, expiresIn } = await this.authFlow.handleCallback(
|
||||
code,
|
||||
state,
|
||||
);
|
||||
res.cookie(SESSION_COOKIE_NAME, sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: this.environment.appBaseUrl.startsWith('https://'),
|
||||
maxAge: expiresIn * 1000,
|
||||
path: '/',
|
||||
});
|
||||
res.redirect(`${this.environment.appBaseUrl}/trips`);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const sessionId = (req.cookies as Record<string, string> | undefined)?.[
|
||||
SESSION_COOKIE_NAME
|
||||
];
|
||||
if (sessionId) await this.sessionStore.deleteSession(sessionId);
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
|
||||
res.status(204).send();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { AuthSessionController } from './auth-session.controller';
|
||||
|
||||
describe('AuthSessionController', () => {
|
||||
it('POST /auth/session exchanges the authorization code via the token exchange service', async () => {
|
||||
const tokenExchange = {
|
||||
exchangeAuthorizationCode: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: 'at-1', expiresIn: 3600 }),
|
||||
};
|
||||
const controller = new AuthSessionController(tokenExchange as never);
|
||||
|
||||
const dto = {
|
||||
code: 'code-1',
|
||||
codeVerifier: 'verifier-1',
|
||||
redirectUri: 'http://localhost:4200/auth/callback',
|
||||
};
|
||||
await expect(controller.createSession(dto)).resolves.toEqual({
|
||||
accessToken: 'at-1',
|
||||
expiresIn: 3600,
|
||||
});
|
||||
expect(tokenExchange.exchangeAuthorizationCode).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { TokenExchangeService } from '../../../../libs/auth/src';
|
||||
import type {
|
||||
AuthorizationCodeExchangeRequest,
|
||||
AuthorizationCodeExchangeResult,
|
||||
} from '../../../../libs/auth/src';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthSessionController {
|
||||
constructor(private readonly tokenExchange: TokenExchangeService) {}
|
||||
|
||||
@Post('session')
|
||||
createSession(
|
||||
@Body() dto: AuthorizationCodeExchangeRequest,
|
||||
): Promise<AuthorizationCodeExchangeResult> {
|
||||
return this.tokenExchange.exchangeAuthorizationCode(dto);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthSessionController } from './auth-session.controller';
|
||||
import { AuthLoginController } from './auth-login.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthSessionController],
|
||||
controllers: [AuthLoginController],
|
||||
})
|
||||
export class AuthApiModule {}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||
const request = ctx
|
||||
.switchToHttp()
|
||||
.getRequest<{ user: AuthenticatedUser }>();
|
||||
(_data: unknown, ctx: ExecutionContext): SessionUser => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user: SessionUser }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { ApiModule } from './api.module';
|
||||
|
||||
export async function bootstrapApi(): Promise<void> {
|
||||
const app = await NestFactory.create(ApiModule);
|
||||
app.enableShutdownHooks();
|
||||
app.use(cookieParser());
|
||||
app.setGlobalPrefix('api/v1', {
|
||||
exclude: [
|
||||
{ path: 'health/live', method: RequestMethod.GET },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TravelersController } from './travelers.controller';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
|
||||
describe('TravelersController', () => {
|
||||
const currentUser: AuthenticatedUser = {
|
||||
const currentUser: SessionUser = {
|
||||
id: 'u1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Alex',
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import { SessionAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
import {
|
||||
TravelersService,
|
||||
TripMembershipGuard,
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
@Controller('trips/:tripId/travelers')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@UseGuards(SessionAuthGuard, TripMembershipGuard)
|
||||
export class TravelersController {
|
||||
constructor(private readonly travelersService: TravelersService) {}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class TravelersController {
|
||||
@Post()
|
||||
create(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
@Body() dto: CreateTravelerDto,
|
||||
): Promise<Traveler> {
|
||||
return this.travelersService.createTraveler(tripId, dto, currentUser.id);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TripInvitationsController } from './trip-invitations.controller';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
|
||||
describe('TripInvitationsController', () => {
|
||||
const currentUser: AuthenticatedUser = {
|
||||
const currentUser: SessionUser = {
|
||||
id: 'owner-1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Owner',
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import { SessionAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
import {
|
||||
TripInvitationsService,
|
||||
TripMembershipGuard,
|
||||
@@ -26,11 +26,11 @@ export class TripInvitationsController {
|
||||
constructor(private readonly invitationsService: TripInvitationsService) {}
|
||||
|
||||
@Post('trips/:tripId/invitations')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@UseGuards(SessionAuthGuard, TripMembershipGuard)
|
||||
@TripRoles('OWNER')
|
||||
create(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
@Body() dto: CreateTripInvitationDto,
|
||||
): Promise<{ invitation: TripInvitation; rawToken: string }> {
|
||||
return this.invitationsService.createInvitation(
|
||||
@@ -41,14 +41,14 @@ export class TripInvitationsController {
|
||||
}
|
||||
|
||||
@Get('trips/:tripId/invitations')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@UseGuards(SessionAuthGuard, TripMembershipGuard)
|
||||
@TripRoles('OWNER')
|
||||
list(@Param('tripId') tripId: string): Promise<TripInvitation[]> {
|
||||
return this.invitationsService.listInvitations(tripId);
|
||||
}
|
||||
|
||||
@Delete('trips/:tripId/invitations/:invitationId')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@UseGuards(SessionAuthGuard, TripMembershipGuard)
|
||||
@TripRoles('OWNER')
|
||||
remove(
|
||||
@Param('tripId') tripId: string,
|
||||
@@ -58,10 +58,10 @@ export class TripInvitationsController {
|
||||
}
|
||||
|
||||
@Post('invitations/:token/accept')
|
||||
@UseGuards(OidcAuthGuard)
|
||||
@UseGuards(SessionAuthGuard)
|
||||
accept(
|
||||
@Param('token') token: string,
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
): Promise<TripMember> {
|
||||
return this.invitationsService.acceptInvitation(token, currentUser.id);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import { SessionAuthGuard } from '../../../../libs/auth/src';
|
||||
import {
|
||||
TripMembersService,
|
||||
TripMembershipGuard,
|
||||
@@ -25,7 +25,7 @@ interface UpdateTripMemberDto {
|
||||
}
|
||||
|
||||
@Controller('trips/:tripId/members')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@UseGuards(SessionAuthGuard, TripMembershipGuard)
|
||||
export class TripMembersController {
|
||||
constructor(private readonly tripMembersService: TripMembersService) {}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
|
||||
describe('TripPreferenceOverridesController', () => {
|
||||
const member: AuthenticatedUser = {
|
||||
const member: SessionUser = {
|
||||
id: 'u1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Alex',
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import { SessionAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
import {
|
||||
TripMembershipGuard,
|
||||
TripPreferenceOverridesService,
|
||||
@@ -23,7 +23,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { CurrentTripRole } from './current-trip-role.decorator';
|
||||
|
||||
@Controller('trips/:tripId/preference-overrides')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@UseGuards(SessionAuthGuard, TripMembershipGuard)
|
||||
export class TripPreferenceOverridesController {
|
||||
constructor(private readonly service: TripPreferenceOverridesService) {}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class TripPreferenceOverridesController {
|
||||
@Put()
|
||||
async upsert(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
@CurrentTripRole() role: TripMemberRole,
|
||||
@Body() dto: UpsertPreferenceOverrideDto,
|
||||
): Promise<TripPreferenceOverride> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TripsController } from './trips.controller';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
|
||||
describe('TripsController', () => {
|
||||
const currentUser: AuthenticatedUser = {
|
||||
const currentUser: SessionUser = {
|
||||
id: 'u1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Alex',
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import { SessionAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
import {
|
||||
TripMembershipGuard,
|
||||
TripRoles,
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
@Controller('trips')
|
||||
@UseGuards(OidcAuthGuard)
|
||||
@UseGuards(SessionAuthGuard)
|
||||
export class TripsController {
|
||||
constructor(
|
||||
private readonly tripsService: TripsService,
|
||||
@@ -35,13 +35,13 @@ export class TripsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() currentUser: AuthenticatedUser): Promise<Trip[]> {
|
||||
list(@CurrentUser() currentUser: SessionUser): Promise<Trip[]> {
|
||||
return this.tripsService.listTripsForUser(currentUser.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
@Body() dto: CreateTripDto,
|
||||
): Promise<Trip> {
|
||||
return this.tripsService.createTrip(currentUser.id, dto);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UsersController } from './users.controller';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
|
||||
describe('UsersController', () => {
|
||||
const currentUser: AuthenticatedUser = {
|
||||
const currentUser: SessionUser = {
|
||||
id: 'u1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Alex',
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import { SessionAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { SessionUser } from '../../../../libs/auth/src';
|
||||
import {
|
||||
UsersService,
|
||||
UserPreferencesService,
|
||||
@@ -27,7 +27,7 @@ interface UserProfileResponse {
|
||||
}
|
||||
|
||||
@Controller('users/me')
|
||||
@UseGuards(OidcAuthGuard)
|
||||
@UseGuards(SessionAuthGuard)
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
@@ -36,7 +36,7 @@ export class UsersController {
|
||||
|
||||
@Get()
|
||||
async me(
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
): Promise<UserProfileResponse> {
|
||||
const user = await this.usersService.findById(currentUser.id);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
@@ -51,14 +51,14 @@ export class UsersController {
|
||||
|
||||
@Get('preferences')
|
||||
getPreferences(
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
): Promise<UserPreference> {
|
||||
return this.preferencesService.getOrDefault(currentUser.id);
|
||||
}
|
||||
|
||||
@Put('preferences')
|
||||
updatePreferences(
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@CurrentUser() currentUser: SessionUser,
|
||||
@Body() dto: UpdateUserPreferenceDto,
|
||||
): Promise<UserPreference> {
|
||||
return this.preferencesService.upsert(currentUser.id, dto);
|
||||
|
||||
Reference in New Issue
Block a user