ajout app

This commit is contained in:
2024-04-17 20:22:30 +02:00
parent cc017cfc5e
commit f9d05a2fd3
8025 changed files with 729805 additions and 0 deletions

View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,json,yml}]
charset = utf-8
indent_style = space
indent_size = 2

24
Voltaserve/idp/.env Normal file
View File

@ -0,0 +1,24 @@
PORT=7000
# URLs
PUBLIC_UI_URL="http://localhost:3000"
POSTGRES_URL="postgresql://voltaserve:voltaserve@127.0.0.1:5432/voltaserve"
# Token
TOKEN_JWT_SIGNING_KEY="586cozl1x9m6zmu4fg8iwi6ajazguehcm9qdfgd5ndo2pc3pcn"
TOKEN_AUDIENCE="localhost"
TOKEN_ISSUER="localhost"
TOKEN_ACCESS_TOKEN_LIFETIME=86400
TOKEN_REFRESH_TOKEN_LIFETIME=2592000
# CORS
CORS_ORIGINS="http://localhost:3000"
# Search
SEARCH_URL="http://127.0.0.1:7700"
# SMTP
SMTP_HOST="127.0.0.1"
SMTP_PORT=1025
SMTP_SENDER_ADDRESS="no-reply@localhost"
SMTP_SENDER_NAME="Voltaserve"

1
Voltaserve/idp/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
templates/** linguist-detectable=false

2
Voltaserve/idp/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
.env.local

View File

@ -0,0 +1,2 @@
dist
templates/**/*.hbs

View File

@ -0,0 +1,12 @@
{
"singleQuote": true,
"semi": false,
"quoteProps": "preserve",
"importOrder": [
"^@/infra/env$",
"<THIRD_PARTY_MODULES>",
"^@/(.*)$",
"^[./]"
],
"importOrderSeparation": false
}

View File

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

19
Voltaserve/idp/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM registry.suse.com/bci/nodejs:18
WORKDIR /app
COPY src ./src
COPY templates ./templates
COPY .env .
COPY package.json .
COPY pnpm-lock.yaml .
COPY tsconfig.json .
RUN npm install -g corepack
RUN corepack enable
RUN pnpm install
ENTRYPOINT ["pnpm", "run", "start"]
EXPOSE 7000

47
Voltaserve/idp/README.md Normal file
View File

@ -0,0 +1,47 @@
# Voltaserve Identity Provider
## Getting Started
Install dependencies:
```shell
bun i
```
Run for development:
```shell
bun run dev
```
Run for production:
```shell
bun run start
```
Build Docker image:
```shell
docker build -t voltaserve/idp .
```
## Generate and Publish Documentation
Generate `swagger.json`:
```shell
pnpm swagger-autogen && mv ./swagger.json ./docs
```
Preview (will be served at [http://127.0.0.1:7777](http://127.0.0.1:7777)):
```shell
npx @redocly/cli preview-docs --port 7777 ./docs/swagger.json
```
Generate the final static HTML documentation:
```shell
npx @redocly/cli build-docs ./docs/swagger.json --output ./docs/index.html
```

BIN
Voltaserve/idp/bun.lockb Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,176 @@
{
"swagger": "2.0",
"info": {
"title": "Voltaserve Identity Provider",
"version": "1.0.0",
"description": ""
},
"host": "localhost:3000",
"basePath": "/",
"schemes": [
"http"
],
"paths": {
"/v1/health": {
"get": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/user/": {
"get": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
},
"delete": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
}
}
}
},
"/v1/user/update_full_name": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/user/update_email_request": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/user/update_email_confirmation": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/user/update_password": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
}
}
}
},
"/v1/user/update_picture": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/user/delete_picture": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/accounts/": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/accounts/reset_password": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/accounts/confirm_email": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/accounts/send_reset_password_email": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/token/": {
"post": {
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
}
}
}
}
}
}

View File

@ -0,0 +1,13 @@
module.exports = {
files: ['./src/**/*.ts'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
}

View File

@ -0,0 +1,62 @@
{
"name": "voltaserve-idp",
"version": "1.0.0",
"license": "MIT",
"private": true,
"scripts": {
"start": "bun src/app.ts",
"dev": "bun --watch src/app.ts",
"tsc": "tsc --noEmit",
"format": "prettier --write .",
"swagger-autogen": "node ./swagger.js",
"lint": "eslint"
},
"dependencies": {
"body-parser": "1.20.2",
"camelize": "1.0.1",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.19.2",
"express-validator": "7.0.1",
"handlebars": "4.7.8",
"hashids": "2.3.0",
"jose": "5.2.4",
"js-yaml": "4.1.0",
"meilisearch": "0.38.0",
"mime-types": "2.1.35",
"minio": "7.1.3",
"morgan": "1.10.0",
"multer": "1.4.5-lts.1",
"nodemailer": "6.9.13",
"passport": "0.7.0",
"passport-jwt": "4.0.1",
"pg": "8.11.5",
"uuid": "9.0.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "4.3.0",
"@types/body-parser": "1.19.5",
"@types/cors": "2.8.17",
"@types/express": "4.17.21",
"@types/js-yaml": "4.0.9",
"@types/mime-types": "2.1.4",
"@types/minio": "7.1.1",
"@types/morgan": "1.9.9",
"@types/multer": "1.4.11",
"@types/node": "20.12.7",
"@types/nodemailer": "6.4.14",
"@types/passport": "1.0.16",
"@types/passport-jwt": "4.0.1",
"@types/pg": "8.11.5",
"@types/uuid": "9.0.8",
"@typescript-eslint/eslint-plugin": "7.7.0",
"@typescript-eslint/parser": "7.7.0",
"eslint": "9.0.0",
"eslint-config-prettier": "9.1.0",
"prettier": "3.2.5",
"swagger-autogen": "2.23.7",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "5.4.5"
}
}

View File

@ -0,0 +1,91 @@
import { Router, Request, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { parseValidationError } from '@/infra/error'
import {
confirmEmail,
createUser,
resetPassword,
sendResetPasswordEmail,
AccountConfirmEmailOptions,
AccountCreateOptions,
AccountResetPasswordOptions,
AccountSendResetPasswordEmailOptions,
} from './service'
const router = Router()
router.post(
'/',
body('email').isEmail().isLength({ max: 255 }),
body('password').isStrongPassword().isLength({ max: 10000 }),
body('fullName').isString().notEmpty().trim().escape().isLength({ max: 255 }),
body('picture').optional().isBase64().isByteLength({ max: 3000000 }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(await createUser(req.body as AccountCreateOptions))
} catch (err) {
next(err)
}
}
)
router.post(
'/reset_password',
body('token').isString().notEmpty().trim(),
body('newPassword').isStrongPassword(),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
await resetPassword(req.body as AccountResetPasswordOptions)
res.sendStatus(200)
} catch (err) {
next(err)
}
}
)
router.post(
'/confirm_email',
body('token').isString().notEmpty().trim(),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
await confirmEmail(req.body as AccountConfirmEmailOptions)
res.sendStatus(200)
} catch (err) {
next(err)
}
}
)
router.post(
'/send_reset_password_email',
body('email').isEmail().isLength({ max: 255 }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await sendResetPasswordEmail(
req.body as AccountSendResetPasswordEmailOptions
)
)
} catch (err) {
next(err)
}
}
)
export default router

View File

@ -0,0 +1,118 @@
import { getConfig } from '@/config/config'
import { newDateTime } from '@/infra/date-time'
import { ErrorCode, newError } from '@/infra/error'
import { newHashId, newHyphenlessUuid } from '@/infra/id'
import { sendTemplateMail } from '@/infra/mail'
import { hashPassword } from '@/infra/password'
import search, { USER_SEARCH_INDEX } from '@/infra/search'
import userRepo, { User } from '@/user/repo'
import { mapEntity, UserDTO } from '@/user/service'
export type AccountCreateOptions = {
email: string
password: string
fullName: string
picture?: string
}
export type AccountResetPasswordOptions = {
token: string
newPassword: string
}
export type AccountConfirmEmailOptions = {
token: string
}
export type AccountSendResetPasswordEmailOptions = {
email: string
}
export async function createUser(
options: AccountCreateOptions
): Promise<UserDTO> {
const id = newHashId()
if (!(await userRepo.isUsernameAvailable(options.email))) {
throw newError({ code: ErrorCode.UsernameUnavailable })
}
try {
const emailConfirmationToken = newHyphenlessUuid()
const user = await userRepo.insert({
id,
username: options.email,
email: options.email,
fullName: options.fullName,
picture: options.picture,
passwordHash: hashPassword(options.password),
emailConfirmationToken,
createTime: newDateTime(),
})
await search.index(USER_SEARCH_INDEX).addDocuments([
{
id: user.id,
username: user.username,
email: user.email,
fullName: user.fullName,
isEmailConfirmed: user.isEmailConfirmed,
createTime: user.createTime,
},
])
await sendTemplateMail('email-confirmation', options.email, {
'UI_URL': getConfig().publicUIURL,
'TOKEN': emailConfirmationToken,
})
return mapEntity(user)
} catch (error) {
await userRepo.delete(id)
await search.index(USER_SEARCH_INDEX).deleteDocuments([id])
throw newError({ code: ErrorCode.InternalServerError, error })
}
}
export async function resetPassword(options: AccountResetPasswordOptions) {
const user = await userRepo.findByResetPasswordToken(options.token)
await userRepo.update({
id: user.id,
passwordHash: hashPassword(options.newPassword),
})
}
export async function confirmEmail(options: AccountConfirmEmailOptions) {
let user = await userRepo.findByEmailConfirmationToken(options.token)
user = await userRepo.update({
id: user.id,
isEmailConfirmed: true,
emailConfirmationToken: null,
})
await search.index(USER_SEARCH_INDEX).updateDocuments([
{
...user,
isEmailConfirmed: user.isEmailConfirmed,
},
])
}
export async function sendResetPasswordEmail(
options: AccountSendResetPasswordEmailOptions
) {
let user: User
try {
user = await userRepo.findByEmail(options.email)
user = await userRepo.update({
id: user.id,
resetPasswordToken: newHyphenlessUuid(),
})
} catch {
return
}
try {
await sendTemplateMail('reset-password', user.email, {
'UI_URL': getConfig().publicUIURL,
'TOKEN': user.resetPasswordToken,
})
} catch (error) {
const { id } = await userRepo.findByEmail(options.email)
await userRepo.update({ id, resetPasswordToken: null })
throw newError({ code: ErrorCode.InternalServerError, error })
}
}

60
Voltaserve/idp/src/app.ts Normal file
View File

@ -0,0 +1,60 @@
import '@/infra/env'
import bodyParser from 'body-parser'
import cors from 'cors'
import express, { Request, Response } from 'express'
import logger from 'morgan'
import passport from 'passport'
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'
import accountRouter from '@/account/router'
import { getConfig } from '@/config/config'
import { errorHandler } from '@/infra/error'
import tokenRouter from '@/token/router'
import userRepo from '@/user/repo'
import userRouter from '@/user/router'
import { client as postgres } from './infra/postgres'
const app = express()
app.use(cors())
app.use(logger('dev'))
app.use(express.json({ limit: '3mb' }))
app.use(express.urlencoded({ extended: true }))
app.use(bodyParser.json())
const { jwtSigningKey: secretOrKey, issuer, audience } = getConfig().token
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey,
issuer,
audience,
},
async (payload, done) => {
try {
const user = await userRepo.findByID(payload.sub)
return done(null, user)
} catch {
return done(null, false)
}
}
)
)
app.get('/v1/health', (_: Request, res: Response) => {
res.sendStatus(200)
})
app.use('/v1/user', userRouter)
app.use('/v1/accounts', accountRouter)
app.use('/v1/token', tokenRouter)
app.use(errorHandler)
const port = getConfig().port
postgres.connect().then(() => {
app.listen(port, () => {
console.log(`Listening on port ${port}`)
})
})

View File

@ -0,0 +1,62 @@
import { Config } from './types'
let config: Config
export function getConfig(): Config {
if (!config) {
config = new Config()
config.port = parseInt(process.env.PORT)
readURLs(config)
readToken(config)
readCORS(config)
readSearch(config)
readSMTP(config)
}
return config
}
export function readURLs(config: Config) {
config.publicUIURL = process.env.PUBLIC_UI_URL
config.databaseURL = process.env.POSTGRES_URL
}
export function readToken(config: Config) {
config.token.jwtSigningKey = process.env.TOKEN_JWT_SIGNING_KEY
config.token.audience = process.env.TOKEN_AUDIENCE
config.token.issuer = process.env.TOKEN_ISSUER
if (process.env.TOKEN_ACCESS_TOKEN_LIFETIME) {
config.token.accessTokenLifetime = parseInt(
process.env.TOKEN_ACCESS_TOKEN_LIFETIME
)
}
if (process.env.TOKEN_REFRESH_TOKEN_LIFETIME) {
config.token.refreshTokenLifetime = parseInt(
process.env.TOKEN_REFRESH_TOKEN_LIFETIME
)
}
}
export function readCORS(config: Config) {
if (process.env.CORS_ORIGINS) {
config.corsOrigins = process.env.CORS_ORIGINS.split(',')
config.corsOrigins.forEach((e) => e.trim())
}
}
export function readSearch(config: Config) {
config.search.url = process.env.SEARCH_URL
}
export function readSMTP(config: Config) {
config.smtp.host = process.env.SMTP_HOST
if (process.env.SMTP_PORT) {
config.smtp.port = parseInt(process.env.SMTP_PORT)
}
if (process.env.SMTP_SECURE) {
config.smtp.secure = process.env.SMTP_SECURE === 'true'
}
config.smtp.username = process.env.SMTP_USERNAME
config.smtp.password = process.env.SMTP_PASSWORD
config.smtp.senderAddress = process.env.SMTP_SENDER_ADDRESS
config.smtp.senderName = process.env.SMTP_SENDER_NAME
}

View File

@ -0,0 +1,41 @@
export class Config {
port: number
publicUIURL: string
databaseURL: string
token: TokenConfig
corsOrigins: string[]
search: SearchConfig
smtp: SMTPConfig
constructor() {
this.token = new TokenConfig()
this.search = new SearchConfig()
this.smtp = new SMTPConfig()
}
}
export class DatabaseConfig {
url: string
}
export class TokenConfig {
jwtSigningKey: string
audience: string
issuer: string
accessTokenLifetime: number
refreshTokenLifetime: number
}
export class SearchConfig {
url: string
}
export class SMTPConfig {
host: string
port: number
secure: boolean
username?: string
password?: string
senderAddress: string
senderName: string
}

View File

@ -0,0 +1,3 @@
export function newDateTime() {
return new Date().toISOString()
}

View File

@ -0,0 +1,8 @@
import fs from 'fs'
import dotenv from 'dotenv'
if (fs.existsSync('.env.local')) {
dotenv.config({ path: '.env.local' })
} else {
dotenv.config()
}

View File

@ -0,0 +1,119 @@
import { Request, Response, NextFunction } from 'express'
export enum ErrorCode {
InternalServerError = 'internal_server_error',
RequestValidationError = 'request_validation_error',
UsernameUnavailable = 'username_unavailable',
ResourceNotFound = 'resource_not_found',
InvalidUsernameOrPassword = 'invalid_username_or_password',
InvalidPassword = 'invalid_password',
InvalidJwt = 'invalid_jwt',
EmailNotConfimed = 'email_not_confirmed',
RefreshTokenExpired = 'refresh_token_expired',
InvalidRequest = 'invalid_request',
UnsupportedGrantType = 'unsupported_grant_type',
PasswordValidationFailed = 'password_validation_failed',
}
const statuses: { [key: string]: number } = {
[ErrorCode.InternalServerError]: 500,
[ErrorCode.RequestValidationError]: 400,
[ErrorCode.UsernameUnavailable]: 409,
[ErrorCode.ResourceNotFound]: 404,
[ErrorCode.InvalidUsernameOrPassword]: 401,
[ErrorCode.InvalidPassword]: 401,
[ErrorCode.InvalidJwt]: 401,
[ErrorCode.EmailNotConfimed]: 401,
[ErrorCode.RefreshTokenExpired]: 401,
[ErrorCode.InvalidRequest]: 400,
[ErrorCode.UnsupportedGrantType]: 400,
[ErrorCode.PasswordValidationFailed]: 400,
}
const userMessages: { [key: string]: string } = {
[ErrorCode.UsernameUnavailable]: 'Email belongs to an existing user.',
[ErrorCode.EmailNotConfimed]: 'Email not confirmed.',
[ErrorCode.InvalidPassword]: 'Invalid password.',
[ErrorCode.InvalidUsernameOrPassword]: 'Invalid username or password.',
}
export type ErrorData = {
code: string
status: number
message: string
userMessage: string
moreInfo: string
error?: any
}
export type ErrorResponse = {
code: string
status: number
message: string
userMessage: string
moreInfo: string
}
export type ErrorOptions = {
code: ErrorCode
message?: string
userMessage?: string
error?: any
}
export function newError(options: ErrorOptions): ErrorData {
const userMessage =
options.userMessage ||
userMessages[options.code] ||
'Oops! something went wrong'
return {
code: options.code,
status: statuses[options.code],
message: options.message || userMessage,
userMessage,
moreInfo: `https://voltaserve.com/docs/idp/errors/${options.code}`,
error: options.error,
}
}
export function newResponse(data: ErrorData): ErrorResponse {
return {
code: data.code,
status: data.status,
message: data.message,
userMessage: data.userMessage,
moreInfo: data.moreInfo,
}
}
export function errorHandler(
error: any,
_: Request,
res: Response,
next: NextFunction
) {
if (error.code && Object.values(ErrorCode).includes(error.code)) {
const data = error as ErrorData
if (data.error) {
console.error(data.error)
}
res.status(data.status).json(newResponse(data))
} else {
console.error(error)
res
.status(500)
.json(newResponse(newError({ code: ErrorCode.InternalServerError })))
}
next(error)
return
}
export function parseValidationError(result: any): ErrorData {
let message: string
if (result.errors) {
message = result.errors
.map((e: any) => `${e.msg} for ${e.type} ${e.path} in ${e.location}.`)
.join(' ')
}
return newError({ code: ErrorCode.RequestValidationError, message })
}

View File

@ -0,0 +1,10 @@
import { v4 as uuidv4 } from 'uuid'
import hashids from 'hashids'
export function newHashId(): string {
return new hashids(uuidv4()).encode(Date.now())
}
export function newHyphenlessUuid(): string {
return uuidv4().replaceAll('-', '')
}

View File

@ -0,0 +1,59 @@
import fs from 'fs'
import Handlebars from 'handlebars'
import yaml from 'js-yaml'
import nodemailer from 'nodemailer'
import path from 'path'
import { getConfig } from '@/config/config'
type MessageParams = {
subject: string
}
const config = getConfig().smtp
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth:
config.username || config.password
? {
user: config.username,
pass: config.password,
}
: null,
})
export function sendTemplateMail(
template: string,
address: string,
variables: Record<string, any>
) {
const params = yaml.load(
fs.readFileSync(path.join('templates', template, 'params.yml'), 'utf8')
) as MessageParams
const html = Handlebars.compile(
fs.readFileSync(path.join('templates', template, 'template.hbs'), 'utf8')
)(variables)
const text = Handlebars.compile(
fs.readFileSync(path.join('templates', template, 'template.txt'), 'utf8')
)(variables)
return new Promise<void>((resolve, reject) => {
transporter.sendMail(
{
from: `"${config.senderName}" <${config.senderAddress}>`,
to: address,
subject: params.subject,
text,
html,
},
(err) => {
if (err) {
reject(err)
} else {
resolve()
}
}
)
})
}

View File

@ -0,0 +1,6 @@
import { Request } from 'express'
import { User } from '@/user/repo'
export interface PassportRequest extends Request {
user: User
}

View File

@ -0,0 +1,13 @@
import { scryptSync, randomBytes } from 'crypto'
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex')
const key = scryptSync(password, salt, 64).toString('hex')
return `${key}:${salt}`
}
export function verifyPassword(password: string, hash: string): boolean {
const [key, salt] = hash.split(':')
const newKey = scryptSync(password, salt, 64).toString('hex')
return newKey === key
}

View File

@ -0,0 +1,6 @@
import { Client } from 'pg'
import { getConfig } from '@/config/config'
export const client = new Client({
connectionString: getConfig().databaseURL,
})

View File

@ -0,0 +1,9 @@
import { MeiliSearch } from 'meilisearch'
import { getConfig } from '@/config/config'
export const USER_SEARCH_INDEX = 'user'
const client = new MeiliSearch({ host: getConfig().search.url })
client.createIndex(USER_SEARCH_INDEX, { primaryKey: 'id' })
export default client

View File

@ -0,0 +1,24 @@
import { Router, Request, Response, NextFunction } from 'express'
import { exchange, TokenExchangeOptions } from './service'
const router = Router()
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const options = req.body as TokenExchangeOptions
res.json(await exchange(options))
} catch (err) {
res.status(400)
if (
err.error &&
(err.error === 'invalid_grant' ||
err.error === 'invalid_request' ||
err.error === 'unsupported_grant_type')
) {
res.json(err)
}
next(err)
}
})
export default router

View File

@ -0,0 +1,136 @@
import { SignJWT } from 'jose'
import { getConfig } from '@/config/config'
import { ErrorCode, newError } from '@/infra/error'
import { newHyphenlessUuid } from '@/infra/id'
import { verifyPassword } from '@/infra/password'
import userRepo, { User } from '@/user/repo'
export type TokenGrantType = 'password' | 'refresh_token'
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
export type Token = {
access_token: string
token_type: string
expires_in: number
refresh_token: string
}
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.2
export type TokenExchangeOptions = {
grant_type: TokenGrantType
username?: string
password?: string
refresh_token?: string
}
export async function exchange(options: TokenExchangeOptions): Promise<Token> {
validateParemeters(options)
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.3
if (options.grant_type === 'password') {
let user: User
try {
user = await userRepo.findByUsername(options.username)
} catch {
throw newError({ code: ErrorCode.InvalidUsernameOrPassword })
}
if (!user.isEmailConfirmed) {
throw newError({ code: ErrorCode.EmailNotConfimed })
}
if (verifyPassword(options.password, user.passwordHash)) {
return newToken(user.id)
} else {
throw newError({ code: ErrorCode.InvalidUsernameOrPassword })
}
}
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
if (options.grant_type === 'refresh_token') {
let user: User
try {
user = await userRepo.findByRefreshTokenValue(options.refresh_token)
} catch {
throw newError({ code: ErrorCode.InvalidUsernameOrPassword })
}
if (!user.isEmailConfirmed) {
throw newError({ code: ErrorCode.EmailNotConfimed })
}
if (new Date() >= new Date(user.refreshTokenExpiry)) {
throw newError({ code: ErrorCode.RefreshTokenExpired })
}
return newToken(user.id)
}
}
function validateParemeters(options: TokenExchangeOptions) {
if (!options.grant_type) {
throw newError({
code: ErrorCode.InvalidRequest,
message: 'Missing parameter: grant_type',
})
}
if (
options.grant_type !== 'password' &&
options.grant_type !== 'refresh_token'
) {
throw newError({
code: ErrorCode.UnsupportedGrantType,
message: `Grant type unsupported: ${options.grant_type}`,
})
}
if (options.grant_type === 'password') {
if (!options.username) {
throw newError({
code: ErrorCode.InvalidRequest,
message: 'Missing parameter: username',
})
}
if (!options.password) {
throw newError({
code: ErrorCode.InvalidRequest,
message: 'Missing parameter: password',
})
}
}
if (options.grant_type === 'refresh_token' && !options.refresh_token) {
throw newError({
code: ErrorCode.InvalidRequest,
message: 'Missing parameter: refresh_token',
})
}
}
async function newToken(userId: string): Promise<Token> {
const config = getConfig().token
const expiry = newAccessTokenExpiry()
const jwt = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(config.issuer)
.setAudience(config.audience)
.setExpirationTime(expiry)
.sign(new TextEncoder().encode(config.jwtSigningKey))
const token: Token = {
access_token: jwt,
expires_in: expiry,
token_type: 'Bearer',
refresh_token: newHyphenlessUuid(),
}
const user = await userRepo.findByID(userId)
await userRepo.update({
id: user.id,
refreshTokenValue: token.refresh_token,
refreshTokenExpiry: newRefreshTokenExpiry(),
})
return token
}
function newRefreshTokenExpiry(): string {
const now = new Date()
now.setSeconds(now.getSeconds() + getConfig().token.refreshTokenLifetime)
return now.toISOString()
}
function newAccessTokenExpiry(): number {
const now = new Date()
now.setSeconds(now.getSeconds() + getConfig().token.refreshTokenLifetime)
return Math.floor(now.getTime() / 1000)
}

View File

@ -0,0 +1,315 @@
import { ErrorCode, newError } from '@/infra/error'
import { client } from '@/infra/postgres'
export type User = {
id: string
fullName: string
username: string
email: string
passwordHash: string
refreshTokenValue?: string
refreshTokenExpiry?: string
resetPasswordToken?: string
emailConfirmationToken?: string
isEmailConfirmed: boolean
emailUpdateToken?: string
emailUpdateValue?: string
picture?: string
createTime: string
updateTime?: string
}
export type InsertOptions = {
id: string
fullName?: string
username?: string
email?: string
passwordHash?: string
refreshTokenValue?: string
refreshTokenExpiry?: string
resetPasswordToken?: string
emailConfirmationToken?: string
isEmailConfirmed?: boolean
picture?: string
createTime?: string
updateTime?: string
}
export type UpdateOptions = {
id: string
fullName?: string
username?: string
email?: string
passwordHash?: string
refreshTokenValue?: string
refreshTokenExpiry?: string
resetPasswordToken?: string
emailConfirmationToken?: string
isEmailConfirmed?: boolean
emailUpdateToken?: string
emailUpdateValue?: string
picture?: string
createTime?: string
updateTime?: string
}
export interface UserRepo {
findByID(id: string): Promise<User>
findByUsername(username: string): Promise<User>
findByEmail(email: string): Promise<User>
findByRefreshTokenValue(refreshTokenValue: string): Promise<User>
findByResetPasswordToken(resetPasswordToken: string): Promise<User>
findByEmailConfirmationToken(emailConfirmationToken: string): Promise<User>
findByEmailUpdateToken(emailUpdateToken: string): Promise<User>
findByPicture(picture: string): Promise<User>
isUsernameAvailable(username: string): Promise<boolean>
insert(data: InsertOptions): Promise<User>
update(data: UpdateOptions): Promise<User>
delete(id: string): Promise<void>
}
class UserRepoImpl {
async findByID(id: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE id = $1`,
[id]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with id=${id} not found`,
})
}
return this.mapRow(rows[0])
}
async findByUsername(username: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE username = $1`,
[username]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with username=${username} not found`,
})
}
return this.mapRow(rows[0])
}
async findByEmail(email: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE email = $1`,
[email]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with email=${email} not found`,
})
}
return this.mapRow(rows[0])
}
async findByRefreshTokenValue(refreshTokenValue: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE refresh_token_value = $1`,
[refreshTokenValue]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with refresh_token_value=${refreshTokenValue} not found`,
})
}
return this.mapRow(rows[0])
}
async findByResetPasswordToken(resetPasswordToken: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE reset_password_token = $1`,
[resetPasswordToken]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with reset_password_token=${resetPasswordToken} not found`,
})
}
return this.mapRow(rows[0])
}
async findByEmailConfirmationToken(
emailConfirmationToken: string
): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE email_confirmation_token = $1`,
[emailConfirmationToken]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with email_confirmation_token=${emailConfirmationToken} not found`,
})
}
return this.mapRow(rows[0])
}
async findByEmailUpdateToken(emailUpdateToken: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE email_update_token = $1`,
[emailUpdateToken]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with email_update_token=${emailUpdateToken} not found`,
})
}
return this.mapRow(rows[0])
}
async findByPicture(picture: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE picture = $1`,
[picture]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with picture=${picture} not found`,
})
}
return this.mapRow(rows[0])
}
async isUsernameAvailable(username: string): Promise<boolean> {
const { rowCount } = await client.query(
`SELECT * FROM "user" WHERE username = $1`,
[username]
)
return rowCount === 0
}
async insert(data: InsertOptions): Promise<User> {
const { rowCount, rows } = await client.query(
`INSERT INTO "user" (
id,
full_name,
username,
email,
password_hash,
refresh_token_value,
refresh_token_expiry,
reset_password_token,
email_confirmation_token,
is_email_confirmed,
picture,
create_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[
data.id,
data.fullName,
data.username,
data.email,
data.passwordHash,
data.refreshTokenValue,
data.refreshTokenExpiry,
data.resetPasswordToken,
data.emailConfirmationToken,
data.isEmailConfirmed || false,
data.picture,
new Date().toISOString(),
]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.InternalServerError,
error: `Inserting user with id=${data.id} failed`,
})
}
return this.mapRow(rows[0])
}
async update(data: UpdateOptions): Promise<User> {
const entity = await this.findByID(data.id)
if (!entity) {
throw newError({
code: ErrorCode.InternalServerError,
error: `User with id=${data.id} not found`,
})
}
Object.assign(entity, data)
entity.updateTime = new Date().toISOString()
const { rowCount, rows } = await client.query(
`UPDATE "user"
SET
full_name = $1,
username = $2,
email = $3,
password_hash = $4,
refresh_token_value = $5,
refresh_token_expiry = $6,
reset_password_token = $7,
email_confirmation_token = $8,
is_email_confirmed = $9,
email_update_token = $10,
email_update_value = $11,
picture = $12,
update_time = $13
WHERE id = $14
RETURNING *`,
[
entity.fullName,
entity.username,
entity.email,
entity.passwordHash,
entity.refreshTokenValue,
entity.refreshTokenExpiry,
entity.resetPasswordToken,
entity.emailConfirmationToken,
entity.isEmailConfirmed,
entity.emailUpdateToken,
entity.emailUpdateValue,
entity.picture,
new Date().toISOString(),
entity.id,
]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.InternalServerError,
error: `Inserting user with id=${data.id} failed`,
})
}
return this.mapRow(rows[0])
}
async delete(id: string): Promise<void> {
await client.query('DELETE FROM "user" WHERE id = $1', [id])
}
private mapRow(row: any): User {
return {
id: row.id,
fullName: row.full_name,
username: row.username,
email: row.email,
passwordHash: row.password_hash,
refreshTokenValue: row.refresh_token_value,
refreshTokenExpiry: row.refresh_token_expiry,
resetPasswordToken: row.reset_password_token,
emailConfirmationToken: row.email_confirmation_token,
isEmailConfirmed: row.is_email_confirmed,
emailUpdateToken: row.email_update_token,
emailUpdateValue: row.email_update_value,
picture: row.picture,
createTime: row.create_time,
updateTime: row.update_time,
}
}
}
const userRepo: UserRepo = new UserRepoImpl()
export default userRepo

View File

@ -0,0 +1,183 @@
import fs from 'fs/promises'
import os from 'os'
import { NextFunction, Router, Response } from 'express'
import { body, validationResult } from 'express-validator'
import multer from 'multer'
import passport from 'passport'
import { parseValidationError } from '@/infra/error'
import { PassportRequest } from '@/infra/passport-request'
import {
deleteUser,
getUser,
updateFullName,
updatePicture,
updatePassword,
UserDeleteOptions,
UserUpdateFullNameOptions,
UserUpdatePasswordOptions,
deletePicture,
UserUpdateEmailRequestOptions,
UserUpdateEmailConfirmationOptions,
updateEmailRequest,
updateEmailConfirmation,
} from './service'
const router = Router()
router.get(
'/',
passport.authenticate('jwt', { session: false }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
res.json(await getUser(req.user.id))
} catch (err) {
next(err)
}
}
)
router.post(
'/update_full_name',
passport.authenticate('jwt', { session: false }),
body('fullName').isString().notEmpty().trim().escape().isLength({ max: 255 }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updateFullName(req.user.id, req.body as UserUpdateFullNameOptions)
)
} catch (err) {
next(err)
}
}
)
router.post(
'/update_email_request',
passport.authenticate('jwt', { session: false }),
body('email').isEmail().isLength({ max: 255 }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updateEmailRequest(
req.user.id,
req.body as UserUpdateEmailRequestOptions
)
)
} catch (err) {
next(err)
}
}
)
router.post(
'/update_email_confirmation',
passport.authenticate('jwt', { session: false }),
body('token').isString().notEmpty().trim(),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updateEmailConfirmation(
req.body as UserUpdateEmailConfirmationOptions
)
)
} catch (err) {
next(err)
}
}
)
router.post(
'/update_password',
passport.authenticate('jwt', { session: false }),
body('currentPassword').notEmpty(),
body('newPassword').isStrongPassword(),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updatePassword(req.user.id, req.body as UserUpdatePasswordOptions)
)
} catch (err) {
if (err === 'invalid_password') {
res.status(400).json({ error: err })
return
} else {
next(err)
}
}
}
)
router.post(
'/update_picture',
passport.authenticate('jwt', { session: false }),
multer({
dest: os.tmpdir(),
limits: { fileSize: 3000000, fields: 0, files: 1 },
}).single('file'),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const user = await updatePicture(
req.user.id,
req.file.path,
req.file.mimetype
)
await fs.rm(req.file.path)
res.json(user)
} catch (err) {
next(err)
}
}
)
router.post(
'/delete_picture',
passport.authenticate('jwt', { session: false }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
res.json(await deletePicture(req.user.id))
} catch (err) {
next(err)
}
}
)
router.delete(
'/',
passport.authenticate('jwt', { session: false }),
body('password').isString().notEmpty(),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
await deleteUser(req.user.id, req.body as UserDeleteOptions)
res.sendStatus(200)
} catch (err) {
if (err === 'invalid_password') {
res.status(400).json({ error: err })
return
} else {
next(err)
}
}
}
)
export default router

View File

@ -0,0 +1,191 @@
import fs from 'fs/promises'
import { ErrorCode, newError } from '@/infra/error'
import { hashPassword, verifyPassword } from '@/infra/password'
import search, { USER_SEARCH_INDEX } from '@/infra/search'
import userRepo, { User } from '@/user/repo'
import { newHyphenlessUuid } from '@/infra/id'
import { sendTemplateMail } from '@/infra/mail'
import { getConfig } from '@/config/config'
export type UserDTO = {
id: string
fullName: string
picture: string
email: string
username: string
pendingEmail?: string
}
export type UserUpdateEmailRequestOptions = {
email: string
}
export type UserUpdateEmailConfirmationOptions = {
token: string
}
export type UserUpdateFullNameOptions = {
fullName: string
}
export type UserUpdatePasswordOptions = {
currentPassword: string
newPassword: string
}
export type UserDeleteOptions = {
password: string
}
export async function getUser(id: string): Promise<UserDTO> {
return mapEntity(await userRepo.findByID(id))
}
export async function getByPicture(picture: string): Promise<UserDTO> {
return mapEntity(await userRepo.findByPicture(picture))
}
export async function updateFullName(
id: string,
options: UserUpdateFullNameOptions
): Promise<UserDTO> {
let user = await userRepo.findByID(id)
user = await userRepo.update({ id: user.id, fullName: options.fullName })
await search.index(USER_SEARCH_INDEX).updateDocuments([
{
...user,
fullName: user.fullName,
},
])
return mapEntity(user)
}
export async function updateEmailRequest(
id: string,
options: UserUpdateEmailRequestOptions
): Promise<UserDTO> {
let user = await userRepo.findByID(id)
if (options.email === user.email) {
user = await userRepo.update({
id: user.id,
emailUpdateToken: null,
emailUpdateValue: null,
})
return mapEntity(user)
} else {
let usernameUnavailable = false
try {
await userRepo.findByUsername(options.email)
usernameUnavailable = true
} catch {
// Ignored
}
if (usernameUnavailable) {
throw newError({ code: ErrorCode.UsernameUnavailable })
}
user = await userRepo.update({
id: user.id,
emailUpdateToken: newHyphenlessUuid(),
emailUpdateValue: options.email,
})
try {
await sendTemplateMail('email-update', options.email, {
'EMAIL': options.email,
'UI_URL': getConfig().publicUIURL,
'TOKEN': user.emailUpdateToken,
})
return mapEntity(user)
} catch (error) {
await userRepo.update({
id,
emailUpdateToken: null,
emailUpdateValue: null,
})
throw newError({ code: ErrorCode.InternalServerError, error })
}
}
}
export async function updateEmailConfirmation(
options: UserUpdateEmailConfirmationOptions
) {
let user = await userRepo.findByEmailUpdateToken(options.token)
user = await userRepo.update({
id: user.id,
email: user.emailUpdateValue,
username: user.emailUpdateValue,
emailUpdateToken: null,
emailUpdateValue: null,
})
await search.index(USER_SEARCH_INDEX).updateDocuments([
{
...user,
email: user.email,
username: user.email,
emailUpdateToken: null,
emailUpdateValue: null,
},
])
return mapEntity(user)
}
export async function updatePassword(
id: string,
options: UserUpdatePasswordOptions
): Promise<UserDTO> {
let user = await userRepo.findByID(id)
if (verifyPassword(options.currentPassword, user.passwordHash)) {
user = await userRepo.update({
id: user.id,
passwordHash: hashPassword(options.newPassword),
})
return mapEntity(user)
} else {
throw newError({ code: ErrorCode.PasswordValidationFailed })
}
}
export async function updatePicture(
id: string,
path: string,
contentType: string
): Promise<UserDTO> {
const picture = await fs.readFile(path, { encoding: 'base64' })
const { id: userId } = await userRepo.findByID(id)
const user = await userRepo.update({
id: userId,
picture: `data:${contentType};base64,${picture}`,
})
return mapEntity(user)
}
export async function deletePicture(id: string): Promise<UserDTO> {
let user = await userRepo.findByID(id)
user = await userRepo.update({ id: user.id, picture: null })
return mapEntity(user)
}
export async function deleteUser(id: string, options: UserDeleteOptions) {
const user = await userRepo.findByID(id)
if (verifyPassword(options.password, user.passwordHash)) {
await userRepo.delete(user.id)
await search.index(USER_SEARCH_INDEX).deleteDocuments([user.id])
} else {
throw newError({ code: ErrorCode.InvalidPassword })
}
}
export function mapEntity(entity: User): UserDTO {
const user: UserDTO = {
id: entity.id,
email: entity.email,
username: entity.username,
fullName: entity.fullName,
picture: entity.picture,
pendingEmail: entity.emailUpdateValue,
}
Object.keys(user).forEach(
(index) => !user[index] && user[index] !== undefined && delete user[index]
)
return user
}

11
Voltaserve/idp/swagger.js Normal file
View File

@ -0,0 +1,11 @@
const swaggerAutogen = require('swagger-autogen')()
const doc = {
info: {
title: 'Voltaserve Identity Provider',
},
}
const outputFile = './swagger.json'
const endpointsFiles = ['./src/app.ts']
swaggerAutogen(outputFile, endpointsFiles, doc)

View File

@ -0,0 +1 @@
subject: 'Confirm your email address'

View File

@ -0,0 +1,51 @@
<html>
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet" />
<style>
body {
font-family: "Inter", sans-serif;
font-size: 13px;
color: black;
}
.link {
color: white !important;
cursor: pointer;
text-decoration: none;
text-decoration-line: none;
text-decoration-style: initial;
text-decoration-color: initial;
background: #0C4CF3;
padding: 0 25px;
height: 40px;
line-height: 40px;
border-radius: 30px;
display: inline-block;
}
.container {
width: 580px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>
<body>
<div class="container">
<p>Hello,</p>
<p>
Welcome to Voltaserve! Please confirm your email address to get started.
</p>
<p>
<a class="link" href="{{UI_URL}}/confirm-email/{{TOKEN}}">Confirm email</a>
</p>
<p>
Thanks,<br />
Voltaserve
</p>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
Follow this link to confirm your Email: {{UI_URL}}/confirm-email/{{TOKEN}}

View File

@ -0,0 +1 @@
subject: 'Confirm your new email address'

View File

@ -0,0 +1,51 @@
<html>
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet" />
<style>
body {
font-family: "Inter", sans-serif;
font-size: 13px;
color: black;
}
.link {
color: white !important;
cursor: pointer;
text-decoration: none;
text-decoration-line: none;
text-decoration-style: initial;
text-decoration-color: initial;
background: #0C4CF3;
padding: 0 25px;
height: 40px;
line-height: 40px;
border-radius: 30px;
display: inline-block;
}
.container {
width: 580px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>
<body>
<div class="container">
<p>Hello,</p>
<p>
Please confirm your email address: <b>{{EMAIL}}</b>.
</p>
<p>
<a class="link" href="{{UI_URL}}/update-email/{{TOKEN}}">Confirm email</a>
</p>
<p>
Thanks,<br />
Voltaserve
</p>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
Please follow this link: {{UI_URL}}/update-email/{{TOKEN}}, to confirm your email address: {{EMAIL}}.

View File

@ -0,0 +1 @@
subject: 'Reset your password'

View File

@ -0,0 +1,49 @@
<html>
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet" />
<style>
body {
font-family: "Inter", sans-serif;
font-size: 13px;
color: #4a4a4a;
}
.link {
color: white !important;
cursor: pointer;
text-decoration: none;
text-decoration-line: none;
text-decoration-style: initial;
text-decoration-color: initial;
background: #0C4CF3;
padding: 0 25px;
height: 40px;
line-height: 40px;
border-radius: 30px;
display: inline-block;
}
.container {
width: 580px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>
<body>
<div class="container">
<p>Hello,</p>
<p>Follow this link to reset your password.</p>
<p>
<a class="link" href="{{UI_URL}}/reset-password/{{TOKEN}}">Reset password</a>
</p>
<p>
Thanks,<br />
Voltaserve
</p>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
Follow this link to reset your password: {{UI_URL}}/reset-password/{{TOKEN}}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"module": "commonjs",
"esModuleInterop": true,
"target": "es2021",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "build",
"experimentalDecorators": true
}
}