webpack frontend stuff, jwt start, settings start
This commit is contained in:
parent
dc7f4215af
commit
d7fc152c8a
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,6 +38,7 @@ lerna-debug.log*
|
||||
.env
|
||||
/devdocker
|
||||
/config*.toml
|
||||
/private
|
||||
|
||||
# front-end items
|
||||
/public/js
|
||||
|
3669
package-lock.json
generated
3669
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -8,13 +8,13 @@
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build && npm run build:fe",
|
||||
"build:fe": "rimraf public/css && sass --no-source-map --style=compressed src/scss/_index.scss:public/css/index.css",
|
||||
"build:fe": "rimraf public/css public/js && NODE_ENV=production webpack --mode=production",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:fe": "rimraf public/css && sass --watch --update --style=expanded src/scss/_index.scss:public/css/index.css",
|
||||
"start:fe": "rimraf public/css public/js && webpack --mode=development -w",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@ -33,6 +33,7 @@
|
||||
"class-validator": "^0.13.2",
|
||||
"dotenv": "^16.0.0",
|
||||
"express-session": "^1.17.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mysql2": "^2.3.3",
|
||||
"nodemailer": "^6.7.2",
|
||||
"otplib": "^12.0.1",
|
||||
@ -46,6 +47,8 @@
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@nestjs/cli": "^8.0.0",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
@ -53,6 +56,7 @@
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
@ -60,19 +64,26 @@
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"babel-loader": "^8.2.3",
|
||||
"css-loader": "^6.7.1",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.5",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"prettier": "^2.3.2",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^12.6.0",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"text-loader": "^0.0.1",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.3.5"
|
||||
"typescript": "^4.3.5",
|
||||
"webpack": "^5.70.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
@ -8,6 +8,7 @@ import { ConfigurationModule } from './modules/config/config.module';
|
||||
import { LoginModule } from './modules/features/login/login.module';
|
||||
import { OAuth2Module } from './modules/features/oauth2/oauth2.module';
|
||||
import { RegisterModule } from './modules/features/register/register.module';
|
||||
import { SettingsModule } from './modules/features/settings/settings.module';
|
||||
import { TwoFactorModule } from './modules/features/two-factor/two-factor.module';
|
||||
import { DatabaseModule } from './modules/objects/database/database.module';
|
||||
import { EmailModule } from './modules/objects/email/email.module';
|
||||
@ -35,6 +36,7 @@ import { UtilityModule } from './modules/utility/utility.module';
|
||||
RegisterModule,
|
||||
OAuth2Module,
|
||||
TwoFactorModule,
|
||||
SettingsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, CSRFMiddleware],
|
||||
|
47
src/fe/scss/_settings.scss
Normal file
47
src/fe/scss/_settings.scss
Normal file
@ -0,0 +1,47 @@
|
||||
.settings {
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
|
||||
&__nav {
|
||||
padding: 2rem 0rem;
|
||||
background-color: #005b74;
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 1rem;
|
||||
border-right: 4px solid transparent;
|
||||
transition: outline 0.15s linear;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:focus-visible {
|
||||
border-right-color: #519eb9;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 4px solid #00c0ff8a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 2.75rem;
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
@import 'flex';
|
||||
@import 'alert';
|
||||
@import 'authorize';
|
||||
@import 'settings';
|
||||
|
||||
*,
|
||||
*::before,
|
0
src/fe/ts/index.ts
Normal file
0
src/fe/ts/index.ts
Normal file
@ -8,6 +8,12 @@ export interface SMTPConfiguration {
|
||||
};
|
||||
}
|
||||
|
||||
export interface JWTConfiguration {
|
||||
algorithm: string;
|
||||
issuer: string;
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
export interface EmailConfiguration {
|
||||
from: string;
|
||||
smtp: SMTPConfiguration;
|
||||
@ -22,4 +28,5 @@ export interface AppConfiguration {
|
||||
export interface Configuration {
|
||||
app: AppConfiguration;
|
||||
email: EmailConfiguration;
|
||||
jwt: JWTConfiguration;
|
||||
}
|
||||
|
@ -31,6 +31,11 @@ export const configProviders = [
|
||||
},
|
||||
},
|
||||
},
|
||||
jwt: {
|
||||
algorithm: 'RS256',
|
||||
issuer: 'localhost',
|
||||
expiration: 3600,
|
||||
},
|
||||
} as Configuration,
|
||||
},
|
||||
{
|
||||
|
@ -18,8 +18,8 @@ export class LoginModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(ValidateCSRFMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.POST });
|
||||
.forRoutes({ path: 'login*', method: RequestMethod.POST });
|
||||
|
||||
consumer.apply(FlashMiddleware).forRoutes('*');
|
||||
consumer.apply(FlashMiddleware).forRoutes('login*');
|
||||
}
|
||||
}
|
||||
|
@ -79,10 +79,16 @@ export class OAuth2Controller {
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
|
||||
// Standard claims
|
||||
name: user.display_name,
|
||||
preferred_username: user.username,
|
||||
nickname: user.display_name,
|
||||
};
|
||||
|
||||
if (token.scope.includes('email') || token.scope.includes('user:email')) {
|
||||
userData.email = user.email;
|
||||
userData.email_verified = true;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -17,8 +17,8 @@ export class RegisterModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(ValidateCSRFMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.POST });
|
||||
.forRoutes({ path: 'register*', method: RequestMethod.POST });
|
||||
|
||||
consumer.apply(FlashMiddleware).forRoutes('*');
|
||||
consumer.apply(FlashMiddleware).forRoutes('register*');
|
||||
}
|
||||
}
|
||||
|
32
src/modules/features/settings/settings.controller.ts
Normal file
32
src/modules/features/settings/settings.controller.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Redirect,
|
||||
Render,
|
||||
Req,
|
||||
Session,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Controller('/account')
|
||||
export class SettingsController {
|
||||
constructor(
|
||||
private readonly _service: SettingsService,
|
||||
private readonly _form: FormUtilityService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Redirect('/account/general')
|
||||
public redirectGeneral() {
|
||||
return;
|
||||
}
|
||||
|
||||
@Get('general')
|
||||
@Render('settings/general')
|
||||
public general(@Req() req: Request, @Session() sess: SessionData) {
|
||||
return this._form.populateTemplate(req, sess);
|
||||
}
|
||||
}
|
34
src/modules/features/settings/settings.module.ts
Normal file
34
src/modules/features/settings/settings.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
RequestMethod,
|
||||
} from '@nestjs/common';
|
||||
import { AuthMiddleware } from 'src/middleware/auth.middleware';
|
||||
import { FlashMiddleware } from 'src/middleware/flash.middleware';
|
||||
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
|
||||
import { ConfigurationModule } from 'src/modules/config/config.module';
|
||||
import { UserModule } from 'src/modules/objects/user/user.module';
|
||||
import { OAuth2Module } from '../oauth2/oauth2.module';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
imports: [ConfigurationModule, UserModule, OAuth2Module],
|
||||
providers: [SettingsService],
|
||||
})
|
||||
export class SettingsModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(ValidateCSRFMiddleware)
|
||||
.forRoutes(
|
||||
{ path: '/account*', method: RequestMethod.POST },
|
||||
{ path: '/account*', method: RequestMethod.PATCH },
|
||||
{ path: '/account*', method: RequestMethod.DELETE },
|
||||
);
|
||||
|
||||
consumer.apply(AuthMiddleware).forRoutes('account*');
|
||||
consumer.apply(FlashMiddleware).forRoutes('account*');
|
||||
}
|
||||
}
|
11
src/modules/features/settings/settings.service.ts
Normal file
11
src/modules/features/settings/settings.service.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||
import { UserService } from 'src/modules/objects/user/user.service';
|
||||
import { OAuth2Service } from '../oauth2/oauth2.service';
|
||||
|
||||
export class SettingsService {
|
||||
constructor(
|
||||
public user: UserService,
|
||||
public oauth: OAuth2Service,
|
||||
public config: ConfigurationService,
|
||||
) {}
|
||||
}
|
@ -11,7 +11,7 @@ import { TwoFactorController } from './two-factor.controller';
|
||||
})
|
||||
export class TwoFactorModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(AuthMiddleware).forRoutes('/account/two-factor/activate');
|
||||
consumer.apply(FlashMiddleware).forRoutes('*');
|
||||
consumer.apply(AuthMiddleware).forRoutes('account/two-factor/activate');
|
||||
consumer.apply(FlashMiddleware).forRoutes('account/two-factor/activate');
|
||||
}
|
||||
}
|
||||
|
11
src/modules/jwt/jwt.module.ts
Normal file
11
src/modules/jwt/jwt.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigurationModule } from '../config/config.module';
|
||||
import { jwtProviders } from './jwt.providers';
|
||||
import { JWTService } from './jwt.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigurationModule],
|
||||
providers: [...jwtProviders, JWTService],
|
||||
exports: [JWTService],
|
||||
})
|
||||
export class JWTModule {}
|
19
src/modules/jwt/jwt.providers.ts
Normal file
19
src/modules/jwt/jwt.providers.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { join } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export const jwtProviders = [
|
||||
{
|
||||
provide: 'PRIVATE_PATH',
|
||||
useValue: join(__dirname, '..', '..', '..', 'private'),
|
||||
},
|
||||
{
|
||||
provide: 'JWT_PRIVATE_KEY',
|
||||
useFactory: async (path: string) => readFile(join(path, 'jwt.private.pem')),
|
||||
inject: ['PRIVATE_PATH'],
|
||||
},
|
||||
{
|
||||
provide: 'JWT_PUBLIC_KEY',
|
||||
useFactory: async (path: string) => readFile(join(path, 'jwt.public.pem')),
|
||||
inject: ['PRIVATE_PATH'],
|
||||
},
|
||||
];
|
39
src/modules/jwt/jwt.service.ts
Normal file
39
src/modules/jwt/jwt.service.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigurationService } from '../config/config.service';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
@Injectable()
|
||||
export class JWTService {
|
||||
constructor(
|
||||
@Inject('JWT_PRIVATE_KEY') private _privateKey: string,
|
||||
@Inject('JWT_PUBLIC_KEY') private _publicKey: string,
|
||||
private _config: ConfigurationService,
|
||||
) {}
|
||||
|
||||
public issue(
|
||||
claims: Record<string, any>,
|
||||
subject: string,
|
||||
audience?: string,
|
||||
): string {
|
||||
return jwt.sign(claims, this._privateKey, {
|
||||
algorithm: this._config.get('jwt.algorithm'),
|
||||
issuer: this._config.get('jwt.issuer'),
|
||||
expiresIn: this._config.get('jwt.expiration'),
|
||||
subject,
|
||||
audience,
|
||||
});
|
||||
}
|
||||
|
||||
public verify(
|
||||
token: string,
|
||||
subject?: string,
|
||||
audience?: string,
|
||||
): jwt.JwtPayload {
|
||||
return jwt.verify(token, this._publicKey, {
|
||||
algorithms: [this._config.get('jwt.algorithm')],
|
||||
issuer: this._config.get('jwt.issuer'),
|
||||
subject,
|
||||
audience,
|
||||
}) as jwt.JwtPayload;
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigurationModule } from 'src/modules/config/config.module';
|
||||
import { databaseProviders } from './database.providers';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigurationModule],
|
||||
providers: [...databaseProviders],
|
||||
exports: [...databaseProviders],
|
||||
})
|
||||
|
@ -1,19 +1,14 @@
|
||||
import { createConnection } from 'typeorm';
|
||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||
import { ConnectionOptions, createConnection } from 'typeorm';
|
||||
|
||||
export const databaseProviders = [
|
||||
{
|
||||
provide: 'DATABASE_CONNECTION',
|
||||
useFactory: async () =>
|
||||
await createConnection({
|
||||
type: 'mysql',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
username: 'icyauth',
|
||||
password: 'icyauth',
|
||||
database: 'icyauth',
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
synchronize: true,
|
||||
logging: ['query', 'error'],
|
||||
}),
|
||||
useFactory: async (config: ConfigurationService) => {
|
||||
return await createConnection({
|
||||
...config.get<ConnectionOptions>('database'),
|
||||
});
|
||||
},
|
||||
inject: [ConfigurationService],
|
||||
},
|
||||
];
|
||||
|
14
src/modules/objects/database/ormconfig.js
Normal file
14
src/modules/objects/database/ormconfig.js
Normal file
@ -0,0 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { join } = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const { readFileSync } = require('fs');
|
||||
const toml = require('toml');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const CONFIG_ENV = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
|
||||
const CONFIG_FILENAME = process.env.CONFIG || `config.${CONFIG_ENV}.toml`;
|
||||
const CONFIG_PATH = join(process.cwd(), CONFIG_FILENAME);
|
||||
const config = toml.parse(readFileSync(CONFIG_PATH, { encoding: 'utf-8' }));
|
||||
|
||||
module.exports = config.database;
|
@ -20,16 +20,25 @@ export class FormUtilityService {
|
||||
* Include CSRF token, messages and prefilled form values for a template with a form
|
||||
* @param req Express request
|
||||
* @param session Express session
|
||||
* @param additional Additional data to pass
|
||||
* @returns Template locals
|
||||
*/
|
||||
public populateTemplate(
|
||||
req: Request,
|
||||
session: SessionData,
|
||||
additional: Record<string, any> = {},
|
||||
): Record<string, any> {
|
||||
const message = req.flash('message')[0] || {};
|
||||
const form = this.mergeObjectArray(
|
||||
(req.flash('form') as Record<string, any>[]) || [],
|
||||
);
|
||||
return { csrf: session.csrf, message, form };
|
||||
|
||||
return {
|
||||
path: req.originalUrl,
|
||||
csrf: session.csrf,
|
||||
message,
|
||||
form,
|
||||
...additional,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -21,5 +21,5 @@
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Authorize application
|
||||
|Authorize application | Icy Network
|
||||
|
||||
block body
|
||||
include partials/logo.pug
|
||||
|
@ -1,7 +1,7 @@
|
||||
extends ../partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Log in
|
||||
|Log in | Icy Network
|
||||
|
||||
block body
|
||||
include ../partials/logo.pug
|
||||
|
@ -1,7 +1,7 @@
|
||||
extends ../partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Set a new password
|
||||
|Set a new password | Icy Network
|
||||
|
||||
block body
|
||||
include ../partials/logo.pug
|
||||
|
@ -1,7 +1,7 @@
|
||||
extends ../partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Veify two-factor
|
||||
|Veify two-factor | Icy Network
|
||||
|
||||
block body
|
||||
include ../partials/logo.pug
|
||||
|
@ -14,6 +14,7 @@ html(lang="en")
|
||||
meta(name="twitter:description", content="Icy Network is a Single-Sign-On (SSO) provider")
|
||||
block links
|
||||
link(rel="stylesheet", type="text/css", href="/public/css/index.css")
|
||||
script(href="/public/js/app.bundle.js")
|
||||
title
|
||||
block title
|
||||
body
|
||||
|
@ -1,7 +1,7 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Register
|
||||
|Register | Icy Network
|
||||
|
||||
block body
|
||||
include partials/logo.pug
|
||||
|
7
views/settings/general.pug
Normal file
7
views/settings/general.pug
Normal file
@ -0,0 +1,7 @@
|
||||
extends ./layout.pug
|
||||
|
||||
block title
|
||||
|General - Account settings | Icy Network
|
||||
|
||||
block settings
|
||||
h1 General settings
|
16
views/settings/layout.pug
Normal file
16
views/settings/layout.pug
Normal file
@ -0,0 +1,16 @@
|
||||
extends ../partials/layout.pug
|
||||
|
||||
block body
|
||||
include ../partials/logo.pug
|
||||
div.container
|
||||
div.center-box.settings
|
||||
nav.sidebar.settings__nav
|
||||
ul
|
||||
li
|
||||
a(href="/account/general") General
|
||||
li
|
||||
a(href="/account/oauth2") Authorizations
|
||||
li
|
||||
a(href="/account/security") Security
|
||||
section.content.settings__content
|
||||
block settings
|
@ -1,7 +1,7 @@
|
||||
extends ../partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Two-factor authentication
|
||||
|Two-factor authentication | Icy Network
|
||||
|
||||
block body
|
||||
include ../partials/logo.pug
|
||||
|
38
webpack.config.js
Normal file
38
webpack.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require('path');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: ['./src/fe/ts/index.ts', './src/fe/scss/index.scss'],
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '../css/index.css',
|
||||
}),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(ts|js)?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env', '@babel/preset-typescript'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(css|scss)/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'public', 'js'),
|
||||
filename: 'app.bundle.js',
|
||||
},
|
||||
devtool: process.env.NODE_ENV !== 'production' ? 'source-map' : undefined,
|
||||
};
|
Reference in New Issue
Block a user