Skip to content

Commit

Permalink
Create settings page (#631)
Browse files Browse the repository at this point in the history
* feat: basic init

* feat: added more style

* feat: add settings page

* fix: update configmap

* fix: linter

* fix: linter build issues

---------

Co-authored-by: orig <[email protected]>
  • Loading branch information
origranot and orig authored Dec 24, 2023
1 parent 44ecaea commit 44cde2b
Show file tree
Hide file tree
Showing 40 changed files with 8,745 additions and 5,166 deletions.
8 changes: 8 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ LOGGER_CONSOLE_THRESHOLD=INFO # DEBUG, INFO, WARN, ERROR, FATAL
DOMAIN=localhost
CLIENTSIDE_API_DOMAIN=http://localhost:3000 # Use this verible, while making client side API calls
API_DOMAIN=http://localhost:3000 # If you are running with docker compose change this to http://backend:3000
STORAGE_DOMAIN=Get it from https://cloud.digitalocean.com/spaces

# DATABASE
# If you are running with docker compose change host to postgres
Expand Down Expand Up @@ -47,3 +48,10 @@ AUTH_GOOGLE_CLIENT_SECRET=Get it from https://console.cloud.google.com/apis/cred

# TRACKER
TRACKER_STATS_QUEUE_NAME=stats

# STORAGE
STORAGE_ENABLE=false
STORAGE_ENDPOINT=Get it from https://cloud.digitalocean.com/spaces
STORAGE_ACCESS_KEY=Get it from https://cloud.digitalocean.com/spaces
STORAGE_SECRET_KEY=Get it from https://cloud.digitalocean.com/spaces
STORAGE_BUCKET_NAME=Get it from https://cloud.digitalocean.com/spaces
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ jobs:
- name: Build & Push Affected Images to Registry
id: deploy-images-to-registry
run: |
npx nx affected -t "push-image-to-registry" --repository=${{ github.repository }} --github-sha="$GITHUB_SHA" --apiDomain=${{ secrets.API_DOMAIN }} --clientSideApiDomain=${{ secrets.CLIENTSIDE_API_DOMAIN }} --domain=${{ secrets.DOMAIN }}
npx nx affected -t "push-image-to-registry" --repository=${{ github.repository }} --github-sha="$GITHUB_SHA" --apiDomain=${{ secrets.API_DOMAIN }} --clientSideApiDomain=${{ secrets.CLIENTSIDE_API_DOMAIN }} --domain=${{ secrets.DOMAIN }} --storageDomain={{ secrets.STORAGE_DOMAIN }}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ For the minimal configuration you can just rename the `.example.env` files to `.
- **DOMAIN**: Domain of your frontend app
- **API_DOMAIN**: Domain of your backend instance (used for server side requests)
- **CLIENTSIDE_API_DOMAIN**: Domain of your backend instance (used for client side requests)
- **STORAGE_DOMAIN**=Domain of your bucket (used for storing images)

###### Redis

Expand Down
8 changes: 7 additions & 1 deletion apps/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LocalAuthGuard } from './guards/local.guard';
import { VerifyAuthGuard } from './guards/verify.guard';
import { UserContext } from './interfaces/user-context';
import { setAuthCookies } from './utils/cookies';
import { UserCtx } from '../shared/decorators';

@Controller({
path: 'auth',
Expand Down Expand Up @@ -43,7 +44,6 @@ export class AuthController {
async signup(@Res() res: Response, @Body() signupDto: SignupDto) {
const user = await this.authService.signup(signupDto);

// Send verification email to user if in production
if (this.appConfigService.getConfig().general.env === 'production') {
await this.novuService.sendVerificationEmail(user);
}
Expand Down Expand Up @@ -104,4 +104,10 @@ export class AuthController {
async verified(@Req() req: Request) {
return this.authService.checkVerification(req.user as UserContext);
}

@UseGuards(JwtAuthGuard)
@Get('/delete')
async delete(@UserCtx() user: UserContext) {
return this.authService.delete(user);
}
}
5 changes: 4 additions & 1 deletion apps/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { VerifyStrategy } from './strategies/verify.strategy';
import { GoogleStrategy } from './strategies/google.strategy';
import { ProvidersController } from './providers/providers.controller';
import { UsersModule } from '../core/users/users.module';
import { StorageModule } from '../storage/storage.module';
import { StorageService } from '../storage/storage.service';

@Module({
imports: [
Expand All @@ -27,10 +29,11 @@ import { UsersModule } from '../core/users/users.module';
}),
}),
NovuModule,
StorageModule,
forwardRef(() => UsersModule),
],
controllers: [AuthController, ProvidersController],
providers: [AuthService, LocalStrategy, JwtStrategy, JwtRefreshStrategy, VerifyStrategy, GoogleStrategy, NovuService],
providers: [AuthService, LocalStrategy, JwtStrategy, JwtRefreshStrategy, VerifyStrategy, GoogleStrategy, NovuService, StorageService],
exports: [AuthService],
})
export class AuthModule {}
5 changes: 5 additions & 0 deletions apps/backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AppConfigModule } from '@reduced.to/config';
import { PrismaService } from '@reduced.to/prisma';
import { AuthService } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { StorageService } from '../storage/storage.service';

describe('AuthService', () => {
let authService: AuthService;
Expand All @@ -30,6 +31,10 @@ describe('AuthService', () => {
},
},
},
{
provide: StorageService,
useValue: jest.fn(),
},
],
}).compile();

Expand Down
19 changes: 19 additions & 0 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { Prisma, PrismaService, ProviderType, Role } from '@reduced.to/prisma';
import * as bcrypt from 'bcryptjs';
import { SignupDto } from './dto/signup.dto';
import { UserContext } from './interfaces/user-context';
import { PROFILE_PICTURE_PREFIX, StorageService } from '../storage/storage.service';

@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly storageService: StorageService,
private readonly appConfigService: AppConfigService
) {}

Expand Down Expand Up @@ -179,4 +181,21 @@ export class AuthService {
...(secret && { secret }),
});
}

async delete(user: UserContext) {
try {
await this.storageService.delete(`${PROFILE_PICTURE_PREFIX}/${user.id}`);
} catch (error) {
// Ignore error
}
return this.prisma.user.delete({
where: {
id: user.id,
},
include: {
authProviders: true,
links: true,
},
});
}
}
4 changes: 4 additions & 0 deletions apps/backend/src/auth/dto/signup.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ export class SignupDto {
@IsOptional()
@IsString()
provider?: ProviderType;

@IsOptional()
@IsString()
profilePicture?: string;
}
11 changes: 9 additions & 2 deletions apps/backend/src/auth/providers/providers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { AppConfigService } from '@reduced.to/config';
import { AuthService } from '../auth.service';
import { ProviderType } from '@reduced.to/prisma';
import { UsersService } from '../../core/users/users.service';
import { setAuthCookies, setCookie } from '../utils/cookies';
import { setAuthCookies } from '../utils/cookies';
import { GoogleOAuthGuard } from '../guards/google-oauth.guard';
import { PROFILE_PICTURE_PREFIX, StorageService } from '../../storage/storage.service';

@Controller({
path: 'auth/providers',
Expand All @@ -15,7 +16,8 @@ export class ProvidersController {
constructor(
private readonly configService: AppConfigService,
private readonly authService: AuthService,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly storageService: StorageService
) {}

@Get('google')
Expand All @@ -38,10 +40,15 @@ export class ProvidersController {
name: req.user.fullName,
email: req.user.email,
password: req.user.providerId,
profilePicture: req.user.picture,
provider: ProviderType.GOOGLE,
});
}

if (req.user.picture && this.configService.getConfig().storage.enable) {
await this.storageService.uploadImageFromUrl(req.user.picture, `${PROFILE_PICTURE_PREFIX}/${user.id}`);
}

const domain = this.configService.getConfig().front.domain;
const urlPrefix = this.configService.getConfig().general.env === 'production' ? `https://${domain}` : `http://${domain}:4200`;

Expand Down
13 changes: 13 additions & 0 deletions apps/backend/src/core/users/dto/update.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IsBase64, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';

export class UpdateDto {
@IsOptional()
@IsString()
@MaxLength(30)
@MinLength(3)
displayName: string;

@IsOptional()
@IsString()
profilePicture?: string;
}
13 changes: 12 additions & 1 deletion apps/backend/src/core/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { IFindAllOptions } from '../entity.service';
import { SortOrder } from '../../shared/enums/sort-order.enum';
import { StorageService } from '../../storage/storage.service';
import { AppLoggerModule } from '@reduced.to/logger';
import { AuthService } from '../../auth/auth.service';

describe('UsersController', () => {
let app: INestApplication;
Expand All @@ -26,7 +29,7 @@ describe('UsersController', () => {

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppConfigModule],
imports: [AppConfigModule, AppLoggerModule],
controllers: [UsersController],
providers: [
{
Expand All @@ -35,6 +38,14 @@ describe('UsersController', () => {
findAll: jest.fn().mockResolvedValue(MOCK_FIND_ALL_RESULT),
},
},
{
provide: StorageService,
useValue: jest.fn(),
},
{
provide: AuthService,
useValue: jest.fn(),
},
],
})
.overrideGuard(JwtAuthGuard)
Expand Down
47 changes: 44 additions & 3 deletions apps/backend/src/core/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Patch, Query, UseGuards } from '@nestjs/common';
import { Role, User } from '@reduced.to/prisma';
import { FindAllQueryDto, CountQueryDto } from './dto';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../shared/decorators';
import { Roles, UserCtx } from '../../shared/decorators';
import { IPaginationResult, calculateSkip } from '../../shared/utils';
import { UpdateDto } from './dto/update.dto';
import { UserContext } from '../../auth/interfaces/user-context';
import { PROFILE_PICTURE_PREFIX, StorageService } from '../../storage/storage.service';
import { AppLoggerSerivce } from '@reduced.to/logger';
import { AppConfigService } from '@reduced.to/config';
import { AuthService } from '../../auth/auth.service';

@UseGuards(JwtAuthGuard, RolesGuard)
@Controller({
path: 'users',
version: '1',
})
export class UsersController {
constructor(private readonly usersService: UsersService) {}
constructor(
private readonly usersService: UsersService,
private readonly config: AppConfigService,
private readonly storageService: StorageService,
private readonly logger: AppLoggerSerivce,
private readonly authService: AuthService
) {}

@Get()
@Roles(Role.ADMIN)
Expand Down Expand Up @@ -46,4 +58,33 @@ export class UsersController {

return { count };
}

@Patch('update')
@UseGuards(JwtAuthGuard)
@Roles(Role.USER)
async update(@UserCtx() user: UserContext, @Body() { displayName, profilePicture }: UpdateDto) {
// Update the user's name if 'displayName' is provided
if (displayName) {
const updatedUser = await this.usersService.updateById(user.id, { name: displayName });
user = { ...user, ...updatedUser };
}

// Handle profile picture upload if 'profilePicture' is provided
if (profilePicture && this.config.getConfig().storage.enable) {
try {
const base64Data = profilePicture.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
await this.storageService.uploadImage({
name: `${PROFILE_PICTURE_PREFIX}/${user.id}`,
file: buffer,
});
} catch (error) {
this.logger.error('Failed to upload profile picture.', error);
}
}

const tokens = await this.authService.generateTokens(user);

return tokens;
}
}
6 changes: 4 additions & 2 deletions apps/backend/src/core/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { PrismaModule } from '@reduced.to/prisma';
import { AuthModule } from '../../auth/auth.module';
import { StorageModule } from '../../storage/storage.module';
import { StorageService } from '../../storage/storage.service';

@Module({
imports: [forwardRef(() => AuthModule), PrismaModule],
imports: [forwardRef(() => AuthModule), PrismaModule, StorageModule],
controllers: [UsersController],
providers: [UsersService],
providers: [UsersService, StorageService],
exports: [UsersService],
})
export class UsersModule {}
9 changes: 9 additions & 0 deletions apps/backend/src/core/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,13 @@ export class UsersService extends EntityService<User> {

return user;
}

async updateById(id: string, data: Prisma.UserUpdateInput): Promise<User> {
return this.prismaService.user.update({
where: {
id,
},
data,
});
}
}
5 changes: 4 additions & 1 deletion apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { useContainer } from 'class-validator';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { AppConfigService } from '@reduced.to/config';
import { AppLoggerSerivce } from '@reduced.to/logger';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';

async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
Expand All @@ -17,6 +18,8 @@ async function bootstrap() {
});

app.use(cookieParser());
app.use(bodyParser.json({ limit: '5mb' }));
app.use(bodyParser.urlencoded({ limit: '5mb', extended: true }));
app.enableCors({ origin: true, credentials: true });
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/shared/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './sortable/sortable.decorator';
export * from './unique/unique.decorator';
export * from './is-verified/is-verified.decorator';
export * from './sortable/sortable.decorator';
export * from './user-ctx/user-ctx.decorator';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';

export const UserCtx = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
});
29 changes: 29 additions & 0 deletions apps/backend/src/storage/storage.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { S3 } from '@aws-sdk/client-s3';
import { Module } from '@nestjs/common';
import { AppConfigService } from '@reduced.to/config';
import { StorageService } from './storage.service';

export const STORAGE_INJECTION_TOKEN = 'STORAGE';

const storageFactory = {
provide: STORAGE_INJECTION_TOKEN,
useFactory: (config: AppConfigService) => {
const client = new S3({
endpoint: config.getConfig().storage.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: config.getConfig().storage.accessKey,
secretAccessKey: config.getConfig().storage.secretKey,
},
});

return client;
},
inject: [AppConfigService],
};

@Module({
providers: [storageFactory],
exports: [storageFactory],
})
export class StorageModule {}
Loading

0 comments on commit 44cde2b

Please sign in to comment.