@ -0,0 +1,339 @@ |
|||||
|
// auth.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import express from 'express'; |
||||
|
import multer from 'multer'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
const ConnectToken = mongoose.model('ConnectToken'); |
||||
|
|
||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||
|
import SvgCaptcha from 'svg-captcha'; |
||||
|
|
||||
|
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
||||
|
|
||||
|
export default class AuthController extends SiteController { |
||||
|
|
||||
|
static get name ( ) { return 'AuthController'; } |
||||
|
static get slug ( ) { return 'auth'; } |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, AuthController); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
const { |
||||
|
csrfToken: csrfTokenService, |
||||
|
limiter: limiterService, |
||||
|
session: sessionService, |
||||
|
} = this.dtp.services; |
||||
|
|
||||
|
this.templates = { |
||||
|
passwordResetComplete: this.loadViewTemplate('auth/password-reset-complete.pug'), |
||||
|
}; |
||||
|
|
||||
|
const upload = multer({ }); |
||||
|
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true }); |
||||
|
|
||||
|
const router = express.Router(); |
||||
|
this.dtp.app.use('/auth', router); |
||||
|
|
||||
|
router.use(async (req, res, next) => { |
||||
|
res.locals.currentView = 'auth'; |
||||
|
return next(); |
||||
|
}); |
||||
|
|
||||
|
router.post( |
||||
|
'/otp/enable', |
||||
|
limiterService.create(limiterService.config.auth.postOtpEnable), |
||||
|
this.postOtpEnable.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.post( |
||||
|
'/otp/auth', |
||||
|
limiterService.create(limiterService.config.auth.postOtpAuthenticate), |
||||
|
this.postOtpAuthenticate.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.post( |
||||
|
'/forgot-password', |
||||
|
limiterService.create(limiterService.config.auth.postForgotPassword), |
||||
|
csrfTokenService.middleware({ name: 'forgot-password' }), |
||||
|
this.postForgotPassword.bind(this), |
||||
|
); |
||||
|
router.post( |
||||
|
'/password-reset', |
||||
|
limiterService.create(limiterService.config.auth.postPasswordReset), |
||||
|
upload.none(), |
||||
|
csrfTokenService.middleware({ name: 'password-reset' }), |
||||
|
this.postPasswordReset.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.post( |
||||
|
'/login', |
||||
|
limiterService.create(limiterService.config.auth.postLogin), |
||||
|
upload.none(), |
||||
|
csrfTokenService.middleware({ name: 'login' }), |
||||
|
this.postLogin.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/api-token/personal', |
||||
|
authRequired, |
||||
|
limiterService.create(limiterService.config.auth.getPersonalApiToken), |
||||
|
this.getPersonalApiToken.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/socket-token', |
||||
|
authRequired, |
||||
|
limiterService.create(limiterService.config.auth.getSocketToken), |
||||
|
this.getSocketToken.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/forgot-password', |
||||
|
limiterService.create(limiterService.config.auth.getForgotPasswordForm), |
||||
|
this.getForgotPasswordForm.bind(this), |
||||
|
); |
||||
|
router.get( |
||||
|
'/password-reset', |
||||
|
limiterService.create(limiterService.config.auth.getResetPasswordForm), |
||||
|
this.getResetPasswordForm.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/logout', |
||||
|
authRequired, |
||||
|
limiterService.create(limiterService.config.auth.getLogout), |
||||
|
this.getLogout.bind(this), |
||||
|
); |
||||
|
|
||||
|
return router; |
||||
|
} |
||||
|
|
||||
|
async postOtpEnable (req, res, next) { |
||||
|
const { otpAuth: otpAuthService } = this.dtp.services; |
||||
|
|
||||
|
const service = req.body['otp-service']; |
||||
|
const secret = req.body['otp-secret']; |
||||
|
const token = req.body['otp-token']; |
||||
|
const otpRedirectURL = req.body['otp-redirect'] || '/'; |
||||
|
|
||||
|
try { |
||||
|
this.log.info('enabling OTP protections', { service, secret, token }); |
||||
|
res.locals.otpAccount = await otpAuthService.createOtpAccount(req, service, secret, token); |
||||
|
res.locals.otpRedirectURL = otpRedirectURL; |
||||
|
res.render('otp/new-account'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to enable OTP protections', { |
||||
|
service, error, |
||||
|
}); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postOtpAuthenticate (req, res, next) { |
||||
|
const { otpAuth: otpAuthService } = this.dtp.services; |
||||
|
|
||||
|
if (!req.user) { |
||||
|
return res.status(403).json({ |
||||
|
success: false, |
||||
|
message: 'Must be logged in', |
||||
|
}); |
||||
|
} |
||||
|
const service = req.body['otp-service']; |
||||
|
if (!service) { |
||||
|
return res.status(400).json({ |
||||
|
success: false, |
||||
|
message: 'Must specify OTP service name', |
||||
|
}); |
||||
|
} |
||||
|
const passcode = req.body['otp-passcode']; |
||||
|
if (!passcode || (typeof passcode !== 'string') || (passcode.length !== 6)) { |
||||
|
return res.status(400).json({ |
||||
|
success: false, |
||||
|
message: 'Must include a valid passcode', |
||||
|
}); |
||||
|
} |
||||
|
try { |
||||
|
await otpAuthService.startOtpSession(req, service, passcode); |
||||
|
return res.redirect(req.body['otp-redirect']); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to verify one-time password for 2FA', { |
||||
|
service, error, |
||||
|
}, req.user); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postForgotPassword (req, res, next) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.account = await userService.requestPasswordReset(req.body.username); |
||||
|
res.render('auth/password-reset-sent'); |
||||
|
} catch (error) { |
||||
|
this.log.error("failed to request password reset", { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postPasswordReset (req, res) { |
||||
|
const { authToken: authTokenService, user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
if (!req.body.password) { |
||||
|
return res.status(200).json({ success: false, message: 'Please enter a new password' }); |
||||
|
} |
||||
|
if ((typeof req.body.password !== 'string') || (req.body.length < 8)) { |
||||
|
return res.status(200).json({ success: false, message: 'The password is invalid or too short' }); |
||||
|
} |
||||
|
if (!req.body.passwordVerify) { |
||||
|
return res.status(200).json({ success: false, message: 'Please also verify your new password' }); |
||||
|
} |
||||
|
if (req.body.password !== req.body.passwordVerify) { |
||||
|
return res.status(200).json({ success: false, message: 'The password and password verify do not match.' }); |
||||
|
} |
||||
|
|
||||
|
res.locals.authToken = await authTokenService.claim(req.body.authToken); |
||||
|
|
||||
|
res.locals.accountId = mongoose.Types.ObjectId(req.body.accountId); |
||||
|
res.locals.account = await userService.getUserAccount(res.locals.accountId); |
||||
|
this.log.alert('updating user password', { |
||||
|
userId: res.locals.account._id, |
||||
|
username: res.locals.account.username_lc, |
||||
|
ip: req.ip, |
||||
|
}); |
||||
|
await userService.updatePassword(res.locals.account, req.body.password); |
||||
|
|
||||
|
const displayList = this.createDisplayList('password-reset'); |
||||
|
displayList.replaceElement('section#view-content', await this.renderTemplate(this.templates.passwordResetComplete, res.locals)); |
||||
|
|
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error("failed to update account password", { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postLogin (req, res, next) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
await userService.login(req, res, next, { redirectUrl: '/' }); |
||||
|
} |
||||
|
|
||||
|
async getPersonalApiToken (req, res, next) { |
||||
|
try { |
||||
|
const { apiGuard: apiGuardService } = this.dtp.platform.services; |
||||
|
res.locals.apiToken = await apiGuardService.createApiToken(req.user, [ |
||||
|
'account-read', |
||||
|
// additional scopes go here
|
||||
|
]); |
||||
|
res.render('api-token/view'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to generate API token', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getSocketToken (req, res, next) { |
||||
|
try { |
||||
|
const token = await ConnectToken.create({ |
||||
|
created: new Date(), |
||||
|
userType: 'User', |
||||
|
user: req.user._id, |
||||
|
consumerType: 'User', |
||||
|
consumer: req.user._id, |
||||
|
token: uuidv4(), |
||||
|
}); |
||||
|
res.status(200).json({ |
||||
|
success: true, |
||||
|
token: token.token |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create Socket.io connect token', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getForgotPasswordForm (req, res) { |
||||
|
const { csrfToken: csrfTokenService } = this.dtp.services; |
||||
|
res.locals.csrfForgotPassword = await csrfTokenService.create(req, { |
||||
|
name: 'forgot-password', |
||||
|
expiresMinutes: 15, |
||||
|
}); |
||||
|
res.locals.captcha = SvgCaptcha.create({ |
||||
|
size: Math.round(Math.random() * 2) + 4, |
||||
|
width: 280, |
||||
|
height: 80, |
||||
|
noise: Math.floor(Math.random() * 2) + 1, |
||||
|
// background: '#d8d8d8',
|
||||
|
color: false, |
||||
|
}); |
||||
|
req.session.dtp = req.session.dtp || { }; |
||||
|
req.session.dtp.captcha = req.session.dtp.captcha || { }; |
||||
|
req.session.dtp.captcha.forgotPassword = res.locals.captcha.text; |
||||
|
|
||||
|
res.render('auth/forgot-password'); |
||||
|
} |
||||
|
|
||||
|
async getResetPasswordForm (req, res, next) { |
||||
|
const { |
||||
|
csrfToken: csrfTokenService, |
||||
|
authToken: authTokenService, |
||||
|
user: userService, |
||||
|
} = this.dtp.services; |
||||
|
try { |
||||
|
// tokens are UUIDv4: efcfc5e3-700c-4d21-9b00-0d1a14a1ab6d
|
||||
|
if (!req.query || !req.query.t) { |
||||
|
throw new SiteError(400, 'Must include authentication token'); |
||||
|
} |
||||
|
|
||||
|
res.locals.authToken = await authTokenService.getByValue(req.query.t); |
||||
|
|
||||
|
res.locals.account = await userService.getUserAccount(res.locals.authToken.data._id); |
||||
|
delete res.locals.account.passwordSalt; |
||||
|
delete res.locals.account.password; |
||||
|
delete res.locals.account.favoriteStickers; |
||||
|
|
||||
|
res.locals.csrfPasswordReset = await csrfTokenService.create(req, { |
||||
|
name: 'password-reset', |
||||
|
expiresMinutes: 15, |
||||
|
}); |
||||
|
|
||||
|
this.log.info('presenting password reset form', { |
||||
|
tokenId: res.locals.authToken._id, |
||||
|
token: res.locals.authToken.token, |
||||
|
userId: res.locals.account._id, |
||||
|
username: res.locals.account.username_lc, |
||||
|
}); |
||||
|
res.render('auth/password-reset.pug'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to present password reset form', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getLogout (req, res, next) { |
||||
|
if (!req.user) { |
||||
|
return next(new SiteError(403, 'You are not signed in')); |
||||
|
} |
||||
|
req.logout((err) => { |
||||
|
if (err) { |
||||
|
this.log.error('failed to destroy browser session', { err }); |
||||
|
return next(err); |
||||
|
} |
||||
|
req.session.destroy((err) => { |
||||
|
if (err) { |
||||
|
this.log.error('failed to destroy browser session', { err }); |
||||
|
return next(err); |
||||
|
} |
||||
|
res.redirect('/'); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,119 @@ |
|||||
|
// email.js
|
||||
|
// Copyright (C) 2024 Digital Telepresence, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import path from 'node:path'; |
||||
|
import express from 'express'; |
||||
|
|
||||
|
import SvgCaptcha from 'svg-captcha'; |
||||
|
|
||||
|
import { SiteController, SiteError} from '../../lib/site-lib.js'; |
||||
|
|
||||
|
export default class EmailController extends SiteController { |
||||
|
|
||||
|
static get name ( ) { return 'EmailController'; } |
||||
|
static get slug ( ) { return 'email'; } |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, EmailController); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
const { jobQueue: jobQueueService, limiter: limiterService } = this.dtp.services; |
||||
|
|
||||
|
SvgCaptcha.loadFont(path.join(this.dtp.config.root, 'client', 'fonts', 'green-nature.ttf')); |
||||
|
|
||||
|
this.emailJobQueue = jobQueueService.getJobQueue('email', { |
||||
|
attempts: 3 |
||||
|
}); |
||||
|
|
||||
|
const router = express.Router(); |
||||
|
this.dtp.app.use('/email', router); |
||||
|
|
||||
|
router.post( |
||||
|
'/verify', |
||||
|
limiterService.create(limiterService.config.email.postEmailVerify), |
||||
|
this.postEmailVerify.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/verify', |
||||
|
limiterService.create(limiterService.config.email.getEmailVerify), |
||||
|
this.getEmailVerify.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/opt-out', |
||||
|
limiterService.create(limiterService.config.email.getEmailOptOut), |
||||
|
this.getEmailOptOut.bind(this), |
||||
|
); |
||||
|
|
||||
|
return router; |
||||
|
} |
||||
|
|
||||
|
async postEmailVerify (req, res, next) { |
||||
|
const { email: emailService } = this.dtp.services; |
||||
|
try { |
||||
|
// if the session doesn't *have* an emailVerify captcha challenge, they
|
||||
|
// didn't start by requesting the form (and are most likely automated)
|
||||
|
if (!req.session.dtp || !req.session.dtp.captcha || !req.session.dtp.captcha.emailVerify) { |
||||
|
throw new SiteError(403, 'Invalid input'); |
||||
|
} |
||||
|
// If the captcha text entered does not exactly match the text stored in
|
||||
|
// the session, reject the request.
|
||||
|
if (req.body.captcha.trim() !== req.session.dtp.captcha.emailVerify) { |
||||
|
throw new SiteError(403, 'The captcha text entered does not match'); |
||||
|
} |
||||
|
|
||||
|
await emailService.verifyToken(req.body.token); |
||||
|
res.render('email/verify-success'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to verify email', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getEmailOptOut (req, res, next) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
await userService.emailOptOut(req.query.u, req.query.c); |
||||
|
res.render('email/opt-out-success'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to opt-out from email', { |
||||
|
userId: req.query.t, |
||||
|
category: req.query.c, |
||||
|
error, |
||||
|
}); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getEmailVerify (req, res, next) { |
||||
|
const { email: emailService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.token = await emailService.getVerificationToken(req.query.t); |
||||
|
if (!res.locals.token) { |
||||
|
throw new SiteError(404, 'Verification token not found'); |
||||
|
} |
||||
|
|
||||
|
res.locals.captcha = SvgCaptcha.create({ |
||||
|
size: Math.round(Math.random() * 2) + 4, |
||||
|
width: 280, |
||||
|
height: 80, |
||||
|
noise: Math.floor(Math.random() * 2) + 1, |
||||
|
// background: '#d8d8d8',
|
||||
|
color: false, |
||||
|
}); |
||||
|
req.session.dtp = req.session.dtp || { }; |
||||
|
req.session.dtp.captcha = req.session.dtp.captcha || { }; |
||||
|
req.session.dtp.captcha.emailVerify = res.locals.captcha.text; |
||||
|
|
||||
|
res.render('email/verify-form'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to verify email', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,477 @@ |
|||||
|
// user.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import express from 'express'; |
||||
|
import mongoose from 'mongoose'; |
||||
|
import multer from 'multer'; |
||||
|
|
||||
|
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
||||
|
|
||||
|
export default class UserController extends SiteController { |
||||
|
|
||||
|
static get name ( ) { return 'UserController'; } |
||||
|
static get slug ( ) { return 'user'; } |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, UserController); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
const { dtp } = this; |
||||
|
const { |
||||
|
csrfToken: csrfTokenService, |
||||
|
limiter: limiterService, |
||||
|
otpAuth: otpAuthService, |
||||
|
session: sessionService, |
||||
|
} = dtp.services; |
||||
|
|
||||
|
const upload = multer({ dest: `/tmp/dtp/${this.dtp.config.site.domainKey}/uploads/${UserController.name}` }); |
||||
|
const router = express.Router(); |
||||
|
dtp.app.use('/user', router); |
||||
|
|
||||
|
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
||||
|
|
||||
|
const otpSetup = otpAuthService.middleware('Account', { |
||||
|
adminRequired: false, |
||||
|
otpRequired: true, |
||||
|
otpRedirectURL: async (req) => { return `/user/${req.user._id}`; }, |
||||
|
}); |
||||
|
const otpMiddleware = otpAuthService.middleware('Account', { |
||||
|
adminRequired: false, |
||||
|
otpRequired: false, |
||||
|
otpRedirectURL: async (req) => { return `/user/${req.user._id}`; }, |
||||
|
}); |
||||
|
|
||||
|
router.use( |
||||
|
async (req, res, next) => { |
||||
|
try { |
||||
|
res.locals.currentView = 'user'; |
||||
|
res.locals.pageTitle = 'Member Account'; |
||||
|
return next(); |
||||
|
} catch (error) { |
||||
|
return next(error); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
async function checkProfileOwner (req, res, next) { |
||||
|
if (!req.user || !req.user._id.equals(res.locals.userProfile._id)) { |
||||
|
return next(new SiteError(403, 'This is not your user account or profile')); |
||||
|
} |
||||
|
return next(); |
||||
|
} |
||||
|
|
||||
|
router.param('userId', this.populateUser.bind(this)); |
||||
|
|
||||
|
router.post( |
||||
|
'/:userId/profile-photo', |
||||
|
limiterService.create(limiterService.config.user.postProfilePhoto), |
||||
|
checkProfileOwner, |
||||
|
upload.single('imageFile'), |
||||
|
this.postProfilePhoto.bind(this), |
||||
|
); |
||||
|
router.post( |
||||
|
'/:userId/settings', |
||||
|
limiterService.create(limiterService.config.user.postUpdateSettings), |
||||
|
checkProfileOwner, |
||||
|
upload.none(), |
||||
|
csrfTokenService.middleware({ name: 'account-settings' }), |
||||
|
this.postUpdateSettings.bind(this), |
||||
|
); |
||||
|
router.post( |
||||
|
'/:userId/block', |
||||
|
limiterService.create(limiterService.config.user.postBlockUser), |
||||
|
upload.none(), |
||||
|
this.postBlockUser.bind(this), |
||||
|
); |
||||
|
|
||||
|
if (process.env.DTP_MEMBER_SIGNUP === 'enabled') { |
||||
|
router.post('/', |
||||
|
limiterService.create(limiterService.config.user.postCreateUser), |
||||
|
csrfTokenService.middleware({ name: 'signup' }), |
||||
|
this.postCreateUser.bind(this), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
router.get( |
||||
|
'/resend-welcome-email', |
||||
|
authCheck, |
||||
|
limiterService.create(limiterService.config.user.getResendWelcomeEmail), |
||||
|
this.getResendWelcomeEmail.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/autocomplete', |
||||
|
authCheck, |
||||
|
this.getAutocomplete.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/resolve', |
||||
|
authCheck, |
||||
|
this.getResolveUsername.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/:userId/otp-setup', |
||||
|
limiterService.create(limiterService.config.user.getOtpSetup), |
||||
|
otpSetup, |
||||
|
this.getOtpSetup.bind(this), |
||||
|
); |
||||
|
router.get( |
||||
|
'/:userId/otp-disable', |
||||
|
limiterService.create(limiterService.config.user.getOtpDisable), |
||||
|
authCheck, |
||||
|
this.getOtpDisable.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/:userId/settings', |
||||
|
limiterService.create(limiterService.config.user.getSettings), |
||||
|
otpMiddleware, |
||||
|
checkProfileOwner, |
||||
|
this.getUserSettingsView.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/:userId/stripe/customer-portal', |
||||
|
limiterService.create(limiterService.config.user.getStripeCustomerPortal), |
||||
|
checkProfileOwner, |
||||
|
this.getStripeCustomerPortal.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/:userId/block', |
||||
|
limiterService.create(limiterService.config.user.getBlockView), |
||||
|
otpMiddleware, |
||||
|
checkProfileOwner, |
||||
|
this.getBlockView.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get( |
||||
|
'/:userId', |
||||
|
limiterService.create(limiterService.config.user.getUserProfile), |
||||
|
otpMiddleware, |
||||
|
checkProfileOwner, |
||||
|
this.getUserView.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.delete( |
||||
|
'/:userId/profile-photo', |
||||
|
limiterService.create(limiterService.config.user.deleteProfilePhoto), |
||||
|
checkProfileOwner, |
||||
|
this.deleteProfilePhoto.bind(this), |
||||
|
); |
||||
|
router.delete( |
||||
|
'/:userId/block/:blockedUserId', |
||||
|
limiterService.create(limiterService.config.user.deleteBlockedUser), |
||||
|
checkProfileOwner, |
||||
|
this.deleteBlockedUser.bind(this), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async populateUser (req, res, next, userId) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
if (!req.user) { |
||||
|
return res.redirect('/welcome'); |
||||
|
} |
||||
|
if (!mongoose.Types.ObjectId.isValid(userId)) { |
||||
|
return next(new SiteError(406, 'Invalid User')); |
||||
|
} |
||||
|
try { |
||||
|
res.locals.userProfile = await userService.getUserAccount(userId); |
||||
|
return next(); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to populate userId', { userId, error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postCreateUser (req, res, next) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
// If the user session doesn't have a signup captcha, then they did not
|
||||
|
// request the signup form and are most likely an automated bot
|
||||
|
if (!req.session.dtp || !req.session.dtp.captcha || !req.session.dtp.captcha.signup) { |
||||
|
throw new SiteError(403, 'Invalid session state'); |
||||
|
} |
||||
|
|
||||
|
// Reject the request if the captcha text entered does not exactly match
|
||||
|
// the text generated
|
||||
|
if (req.body.captcha !== req.session.dtp.captcha.signup) { |
||||
|
throw new SiteError(403, 'The captcha text entered does not match'); |
||||
|
} |
||||
|
|
||||
|
// The captcha matches. Create the user account and sign them in.
|
||||
|
res.locals.user = await userService.create(req.body); |
||||
|
req.login(res.locals.user, (error) => { |
||||
|
if (error) { |
||||
|
return next(error); |
||||
|
} |
||||
|
res.redirect('/'); // send them to the home page
|
||||
|
}); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create new user', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postProfilePhoto (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('profile-photo'); |
||||
|
this.log.info('updating user profile photo', { user: req.user._id, file: req.file }); |
||||
|
const image = await userService.updatePhoto(req.user, req.file); |
||||
|
displayList.showNotification( |
||||
|
'Profile photo updated successfully.', |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
2000, |
||||
|
); |
||||
|
displayList.setAttribute( |
||||
|
'#profile-picture-file', |
||||
|
'src', |
||||
|
`/image/${image._id}`, |
||||
|
); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to update profile photo', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postUpdateSettings (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('app-settings'); |
||||
|
|
||||
|
await userService.setUserSettings(req.user, req.body); |
||||
|
this.log.info('user settings updated', { |
||||
|
username: req.user.username, |
||||
|
newUsername: req.body.username, |
||||
|
}); |
||||
|
|
||||
|
displayList.showNotification( |
||||
|
'Member account settings updated successfully.', |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
6000, |
||||
|
); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to update account settings', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postBlockUser (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('block-user'); |
||||
|
await userService.block(req.user, req.body.userId); |
||||
|
displayList.removeElement(`.streamray-status[data-author-id="${req.body.userId}"]`); |
||||
|
displayList.showNotification( |
||||
|
`Member blocked successfully`, |
||||
|
'success', |
||||
|
'bottom-left', |
||||
|
3000, |
||||
|
); |
||||
|
return res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to block user', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getResendWelcomeEmail (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('welcome-email'); |
||||
|
await userService.sendWelcomeEmail(req.user); |
||||
|
displayList.showNotification( |
||||
|
'Welcome email sent (check spam folder)', |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
4000, |
||||
|
); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to resend welcome email', { error }); |
||||
|
res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getAutocomplete (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
if (!req.query || !req.query.q || (req.query.q.length === 0)) { |
||||
|
throw new SiteError(406, "Username must not be empty"); |
||||
|
} |
||||
|
const strings = await userService.autocomplete({ skip: 0, cpp: 8 }, req.query.q); |
||||
|
res.status(200).json({ success: true, strings }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to autocomplete username', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getResolveUsername (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const user = await userService.lookup(req.query.q); |
||||
|
res.status(200).json({ success: true, user }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to resolve username', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getOtpSetup (req, res) { |
||||
|
res.render('user/otp-setup-complete'); |
||||
|
} |
||||
|
|
||||
|
async getOtpDisable (req, res) { |
||||
|
const { otpAuth: otpAuthService } = this.dtp.services; |
||||
|
try { |
||||
|
await otpAuthService.destroyOtpSession(req, 'Account'); |
||||
|
await otpAuthService.removeForUser(req.user, 'Account'); |
||||
|
res.render('user/otp-disabled'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to disable OTP service for Account', { error }); |
||||
|
res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getStripeCustomerPortal (req, res, next) { |
||||
|
const { stripe: stripeService } = this.dtp.services; |
||||
|
/* |
||||
|
* Stripe Integration |
||||
|
*/ |
||||
|
try { |
||||
|
const session = await stripeService.createCustomerPortalSession(req.user); |
||||
|
return res.redirect(303, session.url); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to provide Stripe Customer Portal session', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getUserSettingsView (req, res, next) { |
||||
|
const { |
||||
|
csrfToken: csrfTokenService, |
||||
|
otpAuth: otpAuthService, |
||||
|
} = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.csrfTokenAccountSettings = await csrfTokenService.create(req, { |
||||
|
name: 'account-settings', |
||||
|
expiresMinutes: 20, |
||||
|
}); |
||||
|
res.locals.hasOtpAccount = await otpAuthService.isUserProtected(req.user, 'Account'); |
||||
|
res.render('user/settings'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to produce user settings view', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getBlockView (req, res, next) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.blockedUsers = await userService.getBlockedUsers(req.user._id); |
||||
|
res.render('user/block-list'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to present block list view', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getUserView (req, res, next) { |
||||
|
const { handcash: handcashService } = this.dtp.services; |
||||
|
/* |
||||
|
* HandCash Integration |
||||
|
*/ |
||||
|
try { |
||||
|
res.locals.handcash = { |
||||
|
connectUrl: handcashService.getRedirectionUrl(req.user), |
||||
|
}; |
||||
|
res.locals.handcash.profile = await handcashService.getUserProfile(req.user); |
||||
|
} catch (error) { |
||||
|
// this.log.debug('authenticated user does not have a HandCash wallet connection', { error });
|
||||
|
// fall through
|
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
res.render('user/profile'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to produce user profile view', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async deleteProfilePhoto (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('app-settings'); |
||||
|
await userService.removePhoto(req.user); |
||||
|
displayList.showNotification( |
||||
|
'Profile photo removed successfully.', |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
2000, |
||||
|
); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to remove profile photo', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async deleteBlockedUser (req, res) { |
||||
|
const { user: userService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('block-user'); |
||||
|
await userService.unblock(req.user, req.params.blockedUserId); |
||||
|
displayList.removeElement(`li[data-user-id="${req.params.blockedUserId}"]`); |
||||
|
displayList.showNotification( |
||||
|
`Member un-blocked successfully`, |
||||
|
'success', |
||||
|
'bottom-left', |
||||
|
3000, |
||||
|
); |
||||
|
return res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to un-block user', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
// chat-message.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const CHANNEL_TYPE_LIST = ['ChatRoom']; |
||||
|
|
||||
|
const ChatMessageSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, |
||||
|
expires: { type: Date, index: -1 }, |
||||
|
channelType: { type: String, enum: CHANNEL_TYPE_LIST, required: true }, |
||||
|
channel: { type: Schema.ObjectId, required: true, index: 1, refPath: 'channelType' }, |
||||
|
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
||||
|
content: { type: String }, |
||||
|
mentions: { type: [Schema.ObjectId], select: false, ref: 'User' }, |
||||
|
hashtags: { type: [String], select: false }, |
||||
|
links: { type: [Schema.ObjectId], ref: 'Link' }, |
||||
|
}); |
||||
|
|
||||
|
export default mongoose.model('ChatMessage', ChatMessageSchema); |
@ -0,0 +1,27 @@ |
|||||
|
// chat-room-invite.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const INVITE_STATUS_LIST = ['new', 'viewed', 'accepted', 'rejected']; |
||||
|
|
||||
|
const InviteeSchema = new Schema({ |
||||
|
user: { }, // pick up here
|
||||
|
email: { }, |
||||
|
}); |
||||
|
|
||||
|
const ChatRoomInviteSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, |
||||
|
token: { type: String, required: true, unique: true }, |
||||
|
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
||||
|
room: { type: Schema.ObjectId, required: true, index: 1, ref: 'ChatRoom' }, |
||||
|
member: { type: InviteeSchema, required: true }, |
||||
|
status: { type: String, enum: INVITE_STATUS_LIST, required: true }, |
||||
|
message: { type: String }, |
||||
|
}); |
||||
|
|
||||
|
export default mongoose.model('ChatRoomInvite', ChatRoomInviteSchema); |
@ -0,0 +1,24 @@ |
|||||
|
// chat-room.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import { MIN_ROOM_CAPACITY, MAX_ROOM_CAPACITY } from './lib/constants.js'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const ChatRoomSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, |
||||
|
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
||||
|
name: { type: String, required: true }, |
||||
|
topic: { type: String }, |
||||
|
capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY }, |
||||
|
accessToken: { type: String, required: true }, |
||||
|
invites: { type: [Schema.ObjectId], select: false }, |
||||
|
members: { type: [Schema.ObjectId], select: false }, |
||||
|
banned: { type: [Schema.ObjectId], select: false }, |
||||
|
}); |
||||
|
|
||||
|
export default mongoose.model('ChatRoom', ChatRoomSchema); |
@ -0,0 +1,29 @@ |
|||||
|
// connect-token.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const RESOURCE_TYPE_LIST = [ |
||||
|
'Channel', |
||||
|
'User', |
||||
|
'ChatRoom', |
||||
|
'ChannelCall', |
||||
|
]; |
||||
|
|
||||
|
const ConnectTokenSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: -1, expires: '2m' }, |
||||
|
token: { type: String, required: true, index: 1 }, |
||||
|
userType: { type: String, enum: ['User', 'CoreUser'] }, |
||||
|
user: { type: Schema.ObjectId, ref: 'User', }, |
||||
|
consumerType: { type: String, enum: ['Channel', 'User'], required: true }, |
||||
|
consumer: { type: Schema.ObjectId, required: true, index: true, refPath: 'consumerType' }, |
||||
|
resourceType: { type: String, enum: RESOURCE_TYPE_LIST }, |
||||
|
resource: { type: Schema.ObjectId, refPath: 'resourceType' }, |
||||
|
claimed: { type: Date }, |
||||
|
}); |
||||
|
|
||||
|
export default mongoose.model('ConnectToken', ConnectTokenSchema); |
@ -0,0 +1,19 @@ |
|||||
|
// csrf-token.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from "mongoose"; |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const CsrfTokenSchema = new Schema({ |
||||
|
created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' }, |
||||
|
expires: { type: Date, required: true, default: Date.now, index: -1 }, |
||||
|
claimed: { type: Date }, |
||||
|
token: { type: String, index: 1 }, |
||||
|
user: { type: Schema.ObjectId, ref: 'User' }, |
||||
|
ip: { type: String, required: true }, |
||||
|
}); |
||||
|
|
||||
|
export default mongoose.model('CsrfToken', CsrfTokenSchema); |
@ -0,0 +1,4 @@ |
|||||
|
'use strict'; |
||||
|
|
||||
|
export const MIN_ROOM_CAPACITY = 4; |
||||
|
export const MAX_ROOM_CAPACITY = 25; |
@ -0,0 +1,36 @@ |
|||||
|
// otp-account.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
var OtpBackupTokenSchema = new Schema({ |
||||
|
token: { type: String, required: true }, |
||||
|
claimed: { type: Date }, |
||||
|
}); |
||||
|
|
||||
|
const OtpAccountSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: -1 }, |
||||
|
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
||||
|
service: { type: String, required: true, index: 1 }, |
||||
|
secret: { type: String, required: true, select: false }, |
||||
|
algorithm: { type: String, required: true }, |
||||
|
step: { type: Number, default: 30, required: true, min: 15 }, |
||||
|
digits: { type: Number, default: 6, required: true, min: 6 }, |
||||
|
backupTokens: { type: [OtpBackupTokenSchema], select: false }, |
||||
|
lastVerification: { type: Date }, |
||||
|
lastVerificationIp: { type: String }, |
||||
|
}); |
||||
|
|
||||
|
OtpAccountSchema.index({ |
||||
|
user: 1, |
||||
|
service: 1, |
||||
|
}, { |
||||
|
unique: true, |
||||
|
name: 'otp_user_svc_uniq_idx', |
||||
|
}); |
||||
|
|
||||
|
export default mongoose.model('OtpAccount', OtpAccountSchema); |
@ -0,0 +1,43 @@ |
|||||
|
// chat.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
// const AuthToken = mongoose.model('AuthToken');
|
||||
|
const ChatRoom = mongoose.model('ChatRoom'); |
||||
|
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
||||
|
|
||||
|
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
||||
|
|
||||
|
export default class ChatService extends SiteService { |
||||
|
|
||||
|
static get name ( ) { return 'ChatService'; } |
||||
|
static get slug () { return 'chat'; } |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, ChatService); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
async createRoom (owner, roomDefinition) { |
||||
|
const room = new ChatRoom(); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
async destroyRoom (user, room) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
async joinRoom (room, user) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
async leaveRoom (room, user) { |
||||
|
|
||||
|
} |
||||
|
} |
@ -0,0 +1,78 @@ |
|||||
|
// csrf-token.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import dayjs from 'dayjs'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||
|
|
||||
|
const CsrfToken = mongoose.model('CsrfToken'); |
||||
|
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
||||
|
|
||||
|
export default class CsrfTokenService extends SiteService { |
||||
|
|
||||
|
static get name ( ) { return 'CsrfTokenService'; } |
||||
|
static get slug ( ) { return 'csrfToken'; } |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, CsrfTokenService); |
||||
|
} |
||||
|
|
||||
|
middleware (options) { |
||||
|
options = Object.assign({ allowReuse: false }, options); |
||||
|
return async (req, res, next) => { |
||||
|
const requestToken = req.body[`csrf-token-${options.name}`]; |
||||
|
if (!requestToken) { |
||||
|
this.log.error('missing CSRF token', { options }); |
||||
|
return next(new Error('Must include valid CSRF token')); |
||||
|
} |
||||
|
|
||||
|
const token = await CsrfToken.findOne({ token: requestToken }); |
||||
|
if (!token) { |
||||
|
return next(new Error('CSRF request token is invalid')); |
||||
|
} |
||||
|
if (token.ip !== req.ip) { |
||||
|
return next(new Error('CSRF request token client mismatch')); |
||||
|
} |
||||
|
if (!options.allowReuse && token.claimed) { |
||||
|
return next(new SiteError(403, 'This request cannot be accepted. Please re-load the form and try again.')); |
||||
|
} |
||||
|
|
||||
|
if (token.user) { |
||||
|
if (!req.user) { |
||||
|
return next(new Error('Must be logged in')); |
||||
|
} |
||||
|
if (!token.user.equals(req.user._id)) { |
||||
|
return next(new Error('CSRF request token user mismatch')); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
await CsrfToken.updateOne( |
||||
|
{ _id: token._id }, |
||||
|
{ $set: { claimed: new Date() } }, |
||||
|
); |
||||
|
|
||||
|
return next(); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
async create (req, options) { |
||||
|
options = Object.assign({ |
||||
|
expiresMinutes: 30, |
||||
|
}, options); |
||||
|
const now = new Date(); |
||||
|
let csrfToken = await CsrfToken.create({ |
||||
|
created: now, |
||||
|
expires: dayjs(now).add(options.expiresMinutes, 'minute').toDate(), |
||||
|
user: req.user ? req.user._id : null, |
||||
|
ip: req.ip, |
||||
|
token: uuidv4(), |
||||
|
}); |
||||
|
csrfToken = csrfToken.toObject(); |
||||
|
csrfToken.name = `csrf-token-${options.name}`; |
||||
|
return csrfToken; |
||||
|
} |
||||
|
} |
@ -0,0 +1,243 @@ |
|||||
|
// otp-auth.js
|
||||
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
|
// All Rights Reserved
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import mongoose from 'mongoose'; |
||||
|
|
||||
|
const OtpAccount = mongoose.model('OtpAccount'); |
||||
|
|
||||
|
const ONE_HOUR = 1000 * 60 * 60; |
||||
|
const OTP_SESSION_DURATION = (process.env.NODE_ENV === 'local') ? (ONE_HOUR * 24) : (ONE_HOUR * 2); |
||||
|
|
||||
|
import { authenticator } from 'otplib'; |
||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||
|
|
||||
|
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
||||
|
|
||||
|
export default class OtpAuthService extends SiteService { |
||||
|
|
||||
|
static get name ( ) { return 'OtpAuthService'; } |
||||
|
static get slug ( ) { return 'otpAuth'; } |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, OtpAuthService); |
||||
|
|
||||
|
authenticator.options = { |
||||
|
algorithm: 'sha1', |
||||
|
step: 30, |
||||
|
digits: 6, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
middleware (serviceName, options) { |
||||
|
options = Object.assign({ |
||||
|
otpRequired: false, |
||||
|
otpRedirectURL: '/', |
||||
|
adminRequired: false, |
||||
|
}, options); |
||||
|
return async (req, res, next) => { |
||||
|
res.locals.otp = { }; // will decorate view model with OTP information
|
||||
|
if (!req.session) { |
||||
|
return next(new SiteError(403, 'Request session is invalid')); |
||||
|
} |
||||
|
if (!req.user) { |
||||
|
return next(new SiteError(403, 'Must be logged in')); |
||||
|
} |
||||
|
if (options.adminRequired && !req.user.flags.isAdmin && !req.user.flags.isModerator) { |
||||
|
return next(new SiteError(403, 'Admin privileges are required')); |
||||
|
} |
||||
|
|
||||
|
req.session.otp = req.session.otp || { }; |
||||
|
if (await this.checkOtpSession(req, serviceName)) { |
||||
|
return next(); // user is OTP-authenticated on this service
|
||||
|
} |
||||
|
|
||||
|
res.locals.otpOptions = authenticator.options; |
||||
|
res.locals.otpServiceName = serviceName; |
||||
|
res.locals.otpAlgorithm = authenticator.options.algorithm.toUpperCase(); |
||||
|
res.locals.otpDigits = authenticator.options.digits; |
||||
|
res.locals.otpPeriod = authenticator.options.step; |
||||
|
|
||||
|
if (typeof options.otpRedirectURL === 'function') { |
||||
|
// allows redirect to things like /user/:userId using current session's user
|
||||
|
res.locals.otpRedirectURL = await options.otpRedirectURL(req, res); |
||||
|
} else { |
||||
|
res.locals.otpRedirectURL = options.otpRedirectURL; |
||||
|
} |
||||
|
|
||||
|
res.locals.otpAccount = await OtpAccount |
||||
|
.findOne({ |
||||
|
user: req.user._id, |
||||
|
service: serviceName, |
||||
|
}); |
||||
|
|
||||
|
if (!res.locals.otpAccount && !options.otpRequired) { |
||||
|
return next(); |
||||
|
} |
||||
|
|
||||
|
if (!res.locals.otpAccount) { |
||||
|
let issuer; |
||||
|
if (process.env.NODE_ENV === 'production') { |
||||
|
issuer = `${this.dtp.config.site.name}: ${serviceName}`; |
||||
|
} else { |
||||
|
issuer = `${this.dtp.config.site.name}:${process.env.NODE_ENV}: ${serviceName}`; |
||||
|
} |
||||
|
res.locals.otpTempSecret = authenticator.generateSecret(); |
||||
|
res.locals.otpKeyURI = authenticator.keyuri(req.user.username.trim(), issuer, res.locals.otpTempSecret); |
||||
|
req.session.otp[serviceName] = req.session.otp[serviceName] || { }; |
||||
|
req.session.otp[serviceName].secret = res.locals.otpTempSecret; |
||||
|
req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL; |
||||
|
return res.render('otp/welcome'); |
||||
|
} |
||||
|
|
||||
|
res.locals.otpSession = req.session.otp[serviceName]; |
||||
|
|
||||
|
this.log.debug('request on OTP-required route with no authentication', { |
||||
|
service: serviceName, |
||||
|
ip: req.ip, |
||||
|
session: res.locals.otpSession, |
||||
|
}, req.user); |
||||
|
|
||||
|
req.session.otp[serviceName] = req.session.otp[serviceName] || { }; |
||||
|
req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL; |
||||
|
await this.saveSession(req); |
||||
|
|
||||
|
if (!res.locals.otpSession || !res.locals.otpSession.isAuthenticated) { |
||||
|
return res.render('otp/authenticate'); |
||||
|
} |
||||
|
|
||||
|
return next(); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
async createOtpAccount (req, service, secret, passcode) { |
||||
|
const { crypto: cryptoService } = this.dtp.services; |
||||
|
const NOW = new Date(); |
||||
|
try { |
||||
|
this.log.info('verifying user passcode', { |
||||
|
user: req.user._id, |
||||
|
username: req.user.username, |
||||
|
service, secret, passcode, |
||||
|
}, req.user); |
||||
|
if (authenticator.check(passcode, secret)) { |
||||
|
throw new SiteError(403, 'Invalid passcode'); |
||||
|
} |
||||
|
|
||||
|
const backupTokens = [ ]; |
||||
|
for (let i = 0; i < 10; ++i) { |
||||
|
backupTokens.push({ |
||||
|
token: cryptoService.createHash(secret + uuidv4()), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const now = new Date(); |
||||
|
const account = await OtpAccount.create({ |
||||
|
created: NOW, |
||||
|
user: req.user._id, |
||||
|
service, |
||||
|
secret, |
||||
|
algorithm: authenticator.options.algorithm, |
||||
|
step: authenticator.options.step, |
||||
|
digits: authenticator.options.digits, |
||||
|
backupTokens, |
||||
|
lastVerification: now, |
||||
|
lastVerificationIp: req.ip, |
||||
|
}); |
||||
|
|
||||
|
return account; |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create OTP account', { |
||||
|
service, secret, passcode, error, |
||||
|
}, req.user); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startOtpSession (req, serviceName, passcode) { |
||||
|
if (!passcode || (typeof passcode !== 'string')) { |
||||
|
throw new SiteError(403, 'Invalid passcode'); |
||||
|
} |
||||
|
try { |
||||
|
const account = await OtpAccount |
||||
|
.findOne({ user: req.user._id, service: serviceName }) |
||||
|
.select('+secret') |
||||
|
.lean(); |
||||
|
if (!account) { |
||||
|
throw new SiteError(400, 'Two-Factor Authentication not enabled'); |
||||
|
} |
||||
|
|
||||
|
const now = new Date(); |
||||
|
if (!authenticator.check(passcode, account.secret)) { |
||||
|
throw new SiteError(403, 'Invalid passcode'); |
||||
|
} |
||||
|
|
||||
|
req.session.otp = req.session.otp || { }; |
||||
|
req.session.otp[serviceName] = req.session.otp[serviceName] || { }; |
||||
|
req.session.otp[serviceName].isAuthenticated = true; |
||||
|
req.session.otp[serviceName].expiresAt = now.valueOf() + OTP_SESSION_DURATION; |
||||
|
await this.saveSession(req); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to start OTP session', { |
||||
|
serviceName, passcode, error, |
||||
|
}); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async destroyOtpSession (req, serviceName) { |
||||
|
if (!req.session.otp) { |
||||
|
return; |
||||
|
} |
||||
|
if (!req.session.otp[serviceName]) { |
||||
|
return; |
||||
|
} |
||||
|
delete req.session.otp[serviceName]; |
||||
|
await this.saveSession(req); |
||||
|
} |
||||
|
|
||||
|
async checkOtpSession (req, serviceName) { |
||||
|
if (!req.session || !req.session.otp) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const session = req.session.otp[serviceName]; |
||||
|
if (!session) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (!session.isAuthenticated) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const NOW = Date.now(); |
||||
|
if (NOW >= session.expiresAt) { |
||||
|
session.isAuthenticated = false; |
||||
|
delete session.expiresAt; |
||||
|
await this.saveSession(req); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
session.expiresAt = NOW + OTP_SESSION_DURATION; |
||||
|
await this.saveSession(req); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async isUserProtected (user, serviceName) { |
||||
|
const account = await OtpAccount.findOne({ user: user._id, service: serviceName }); |
||||
|
if (!account) { |
||||
|
return false; |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async removeForUser (user, serviceName) { |
||||
|
const search = { user: user._id }; |
||||
|
if (serviceName) { |
||||
|
search.service = serviceName; |
||||
|
} |
||||
|
await OtpAccount.deleteMany(search); |
||||
|
} |
||||
|
} |
@ -0,0 +1,29 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
form(method="POST", action="/auth/forgot-password").uk-form |
||||
|
input(type="hidden", name= csrfForgotPassword.name, value= csrfForgotPassword.token) |
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
fieldset.uk-fieldset |
||||
|
legend(class="uk-text-center uk-text-left@m").uk-legend Forgot Password |
||||
|
p Enter the username or email address of the account for which you have lost the password. An email will be sent to the email address on file for the account with instructions for how to proceed. |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="username", class="uk-visible@m").uk-form-label Username or email address |
||||
|
input(id="username", name="username", type="text", placeholder="Enter username or email address").uk-input |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="captcha").uk-form-label Prove you're human |
||||
|
div(uk-grid).uk-grid-small |
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
div(style="background-color: #e8e8e8;").uk-text-center!= captcha.data |
||||
|
div(class="uk-width-1-1 uk-width-expand@m") |
||||
|
input(id="captcha", name="captcha", type="text", placeholder="Enter the text shown").uk-input |
||||
|
.uk-text-small.uk-text-muted The text may include upper-case and lower-case letters and numbers. |
||||
|
|
||||
|
section.uk-section.uk-section-secondary.uk-section-xsmall |
||||
|
.uk-container |
||||
|
.uk-margin |
||||
|
.uk-flex.uk-flex-center |
||||
|
button(type="submit").uk-button.dtp-button-primary Request Password Reset |
@ -0,0 +1,41 @@ |
|||||
|
include ../user/components/profile-picture |
||||
|
section#view-content.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
.uk-card.uk-card-secondary.uk-card-small |
||||
|
.uk-card-header |
||||
|
h1.uk-card-title |
||||
|
div(uk-grid).uk-grid-medium.uk-flex-middle |
||||
|
.uk-width-auto.uk-text-success |
||||
|
i.fas.fa-key |
||||
|
.uk-width-expand Password Reset |
||||
|
if process.env.NODE_ENV === 'local' |
||||
|
.uk-width-auto |
||||
|
.uk-text-small id: #{account._id} |
||||
|
|
||||
|
.uk-card-body |
||||
|
.uk-margin |
||||
|
div(uk-grid) |
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
+renderProfilePicture(account, { iconClass: 'sb-large' }) |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-expand@m") |
||||
|
.uk-margin |
||||
|
if account.displayName |
||||
|
.uk-text-large.uk-text-bold.uk-text-truncate= account.displayName |
||||
|
|
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
.uk-form-label account |
||||
|
div= account.username |
||||
|
|
||||
|
.uk-width-auto |
||||
|
.uk-form-label service |
||||
|
div= site.domain.split(':')[0] |
||||
|
|
||||
|
.uk-margin |
||||
|
p Your password reset is complete, and you can log in now with your new password. |
||||
|
|
||||
|
.uk-card-footer |
||||
|
.uk-flex.uk-flex-right |
||||
|
.uk-width-auto |
||||
|
a(href=`/welcome/login?username=${account.username}`).uk-button.dtp-button-primary.uk-border-rounded Login Now |
@ -0,0 +1,9 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
|
||||
|
h1 Password Reset Email Sent |
||||
|
p A password reset request email has been sent to the email address on file for @#{account.username}. Please follow the instructions in the email to complete the password reset process. |
||||
|
p You can close this tab. |
@ -0,0 +1,79 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
include ../user/components/profile-picture |
||||
|
|
||||
|
section#view-content.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
|
||||
|
form(method="POST", action="/auth/password-reset", autocomplete="off", onsubmit="return dtp.app.submitForm(event, 'reset password');").uk-form |
||||
|
|
||||
|
input(type="hidden", name= csrfPasswordReset.name, value= csrfPasswordReset.token) |
||||
|
input(type="hidden", name= "authToken", value= authToken.token) |
||||
|
input(type="hidden", name= "accountId", value= account._id) |
||||
|
|
||||
|
.uk-card.uk-card-secondary.uk-card-small |
||||
|
.uk-card-header |
||||
|
h1.uk-card-title |
||||
|
div(uk-grid).uk-grid-medium.uk-flex-middle |
||||
|
.uk-width-auto.uk-text-success |
||||
|
i.fas.fa-key |
||||
|
.uk-width-expand Password Reset |
||||
|
if process.env.NODE_ENV === 'local' |
||||
|
.uk-width-auto |
||||
|
.uk-text-small id: #{account._id} |
||||
|
|
||||
|
.uk-card-body |
||||
|
.uk-margin |
||||
|
div(uk-grid) |
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
+renderProfilePicture(account, { iconClass: 'sb-large' }) |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-expand@m") |
||||
|
.uk-margin |
||||
|
if account.displayName |
||||
|
.uk-text-large.uk-text-bold.uk-text-truncate= account.displayName |
||||
|
|
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
.uk-form-label account |
||||
|
div= account.username |
||||
|
|
||||
|
.uk-width-auto |
||||
|
.uk-form-label service |
||||
|
div= site.domain.split(':')[0] |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="password").uk-form-label New password: |
||||
|
input( |
||||
|
id="password", |
||||
|
name="password", |
||||
|
type="password", |
||||
|
minlength="8", |
||||
|
placeholder="Enter new password", |
||||
|
required, |
||||
|
autocomplete="off", |
||||
|
).uk-input |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="password-verify").uk-form-label Verify password: |
||||
|
input( |
||||
|
id="password-verify", |
||||
|
name="passwordVerify", |
||||
|
type="password", |
||||
|
minlength="8", |
||||
|
placeholder="Verify new password", |
||||
|
required, |
||||
|
autocomplete="off", |
||||
|
).uk-input |
||||
|
|
||||
|
.uk-card-footer |
||||
|
.uk-flex.uk-flex-between |
||||
|
.uk-width-auto |
||||
|
a(href="/").uk-button.dtp-button-default.uk-border-rounded |
||||
|
span |
||||
|
i.fas.fa-chevron-left |
||||
|
span.uk-margin-small-left Cancel |
||||
|
|
||||
|
.uk-width-auto |
||||
|
button(type="submit").uk-button.dtp-button-primary.uk-border-rounded Set Password |
@ -0,0 +1,70 @@ |
|||||
|
mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaultImage, currentImage, cropperOptions) |
||||
|
div(id= containerId).dtp-file-upload |
||||
|
form( |
||||
|
method="POST", |
||||
|
action= actionUrl, |
||||
|
enctype="multipart/form-data", |
||||
|
data-image-id= imageId, |
||||
|
onsubmit= "return dtp.app.submitImageForm(event);", |
||||
|
).uk-form |
||||
|
div(uk-grid).uk-flex-middle.uk-flex-center |
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
.upload-image-container.size-512 |
||||
|
if !!currentImage |
||||
|
img(id= imageId, src= `/image/${currentImage._id}`, class= imageClass).sb-large |
||||
|
else |
||||
|
img(id= imageId, src= defaultImage, class= imageClass).sb-large |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
.uk-text-small.uk-margin(hidden= !!currentImage) |
||||
|
if !currentImage |
||||
|
#file-select |
||||
|
.uk-margin(class="uk-text-center uk-text-left@m") |
||||
|
span.uk-text-middle Select an image |
||||
|
div(uk-form-custom).uk-margin-small-left |
||||
|
input( |
||||
|
type="file", |
||||
|
formenctype="multipart/form-data", |
||||
|
accept=".jpg,.png,image/jpeg,image/png", |
||||
|
data-file-select-container= containerId, |
||||
|
data-file-select="test-image-upload", |
||||
|
data-file-size-element= "file-size", |
||||
|
data-file-max-size= 15 * 1024000, |
||||
|
data-image-id= imageId, |
||||
|
data-cropper-options= cropperOptions, |
||||
|
onchange="return dtp.app.selectImageFile(event);", |
||||
|
) |
||||
|
button(type="button", tabindex="-1").uk-button.dtp-button-default Select |
||||
|
|
||||
|
#file-info(class="uk-text-center uk-text-left@m", hidden) |
||||
|
#file-name.uk-text-bold |
||||
|
if currentImage |
||||
|
div resolution: #[span#image-resolution-w= numeral(currentImage.metadata.width).format('0,0')]x#[span#image-resolution-h= numeral(currentImage.metadata.height).format('0,0')] |
||||
|
div size: #[span#file-size= numeral(currentImage.metadata.size).format('0,0.00b')] |
||||
|
div last modified: #[span#file-modified= dayjs(currentImage.created).format('MMM DD, YYYY')] |
||||
|
else |
||||
|
div resolution: #[span#image-resolution-w 512]x#[span#image-resolution-h 512] |
||||
|
div size: #[span#file-size N/A] |
||||
|
div last modified: #[span#file-modified N/A] |
||||
|
|
||||
|
div(class="uk-flex-center", uk-grid).uk-grid-small |
||||
|
#remove-btn(hidden= !currentImage).uk-width-auto |
||||
|
button( |
||||
|
type= "button", |
||||
|
data-image-type= imageId, |
||||
|
onclick= "return dtp.app.removeImageFile(event);", |
||||
|
).uk-button.uk-button-danger Remove |
||||
|
|
||||
|
#file-save-btn(hidden).uk-width-auto |
||||
|
button( |
||||
|
type="submit", |
||||
|
).uk-button.uk-button-primary Save |
||||
|
|
||||
|
block viewjs |
||||
|
script. |
||||
|
window.addEventListener('dtp-load', ( ) => { |
||||
|
window.dtp = window.dtp || { }; |
||||
|
|
||||
|
window.dtp.episode = !{JSON.stringify(episode ? { _id: episode._id, title: episode.title } : { })}; |
||||
|
window.dtp.clip = !{JSON.stringify(clip ? { _id: clip._id, title: clip.title } : { })}; |
||||
|
}); |
@ -0,0 +1,8 @@ |
|||||
|
//- Common routines for all views |
||||
|
- |
||||
|
function getUserPictureUrl (userProfile, which) { |
||||
|
if (!userProfile || !userProfile.picture || !userProfile.picture[which]) { |
||||
|
return `https://${site.domain}/img/default-member.png`; |
||||
|
} |
||||
|
return `https://${site.domain}/image/${userProfile.picture[which]._id}`; |
||||
|
} |
@ -0,0 +1,53 @@ |
|||||
|
nav(style="background: #000000;").uk-navbar-container.uk-light |
||||
|
.uk-container.uk-container-expand |
||||
|
div(uk-navbar) |
||||
|
.uk-navbar-left |
||||
|
a(href="/", aria-label="Back to Home").uk-navbar-item.uk-logo.uk-padding-remove-left |
||||
|
img(src="/img/nav-icon.png").navbar-logo |
||||
|
|
||||
|
ul.uk-navbar-nav |
||||
|
li.uk-active |
||||
|
a(href="/") |
||||
|
span |
||||
|
i.fas.fa-home |
||||
|
span HOME |
||||
|
.uk-navbar-right |
||||
|
if !user |
||||
|
ul.uk-navbar-nav |
||||
|
li |
||||
|
a(href="/welcome/signup") SIGN UP |
||||
|
li |
||||
|
a(href="/welcome/login") LOGIN |
||||
|
else |
||||
|
ul.uk-navbar-nav |
||||
|
li |
||||
|
div.no-select |
||||
|
if user.picture && user.picture.small |
||||
|
img( |
||||
|
src= `/image/${user.picture.small._id}` || '/img/default-member.png', |
||||
|
title="Member Menu", |
||||
|
).profile-navbar |
||||
|
else |
||||
|
img( |
||||
|
src= '/img/default-member.png', |
||||
|
title="Member Menu", |
||||
|
).profile-navbar |
||||
|
div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown |
||||
|
ul.uk-nav.uk-navbar-dropdown-nav(style="z-index: 1024;") |
||||
|
li.uk-nav-heading.uk-text-center.uk-text-truncate= user.displayName || user.username |
||||
|
|
||||
|
li.uk-nav-divider |
||||
|
|
||||
|
li |
||||
|
a(href=`/user/${user._id}/settings`) |
||||
|
span.nav-item-icon |
||||
|
i.fas.fa-cog |
||||
|
span Settings |
||||
|
|
||||
|
if user.flags && (user.flags.isAdmin || user.flags.isModerator) |
||||
|
li.uk-nav-divider |
||||
|
li |
||||
|
a(href='/admin') |
||||
|
span.nav-item-icon |
||||
|
i.fas.fa-user-lock |
||||
|
span Admin |
@ -1,20 +1,22 @@ |
|||||
link(rel="apple-touch-icon" sizes="57x57" href=`/img/icon/${site.domainKey}/icon-57x57.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="57x57" href=`/img/icon/icon-57x57.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="60x60" href=`/img/icon/${site.domainKey}/icon-60x60.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="60x60" href=`/img/icon/icon-60x60.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="72x72" href=`/img/icon/${site.domainKey}/icon-72x72.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="72x72" href=`/img/icon/icon-72x72.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="76x76" href=`/img/icon/${site.domainKey}/icon-76x76.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="76x76" href=`/img/icon/icon-76x76.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="114x114" href=`/img/icon/${site.domainKey}/icon-114x114.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="114x114" href=`/img/icon/icon-114x114.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="120x120" href=`/img/icon/${site.domainKey}/icon-120x120.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="120x120" href=`/img/icon/icon-120x120.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="144x144" href=`/img/icon/${site.domainKey}/icon-144x144.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="144x144" href=`/img/icon/icon-144x144.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="152x152" href=`/img/icon/${site.domainKey}/icon-152x152.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="152x152" href=`/img/icon/icon-152x152.png?v=${pkg.version}`) |
||||
link(rel="apple-touch-icon" sizes="180x180" href=`/img/icon/${site.domainKey}/icon-180x180.png?v=${pkg.version}`) |
link(rel="apple-touch-icon" sizes="180x180" href=`/img/icon/icon-180x180.png?v=${pkg.version}`) |
||||
link(rel="icon" type="image/png" sizes="32x32" href=`/img/icon/${site.domainKey}/icon-32x32.png?v=${pkg.version}`) |
|
||||
link(rel="icon" type="image/png" sizes="96x96" href=`/img/icon/${site.domainKey}/icon-96x96.png?v=${pkg.version}`) |
link(rel="icon" type="image/png" sizes="32x32" href=`/img/icon/icon-32x32.png?v=${pkg.version}`) |
||||
link(rel="icon" type="image/png" sizes="16x16" href=`/img/icon/${site.domainKey}/icon-16x16.png?v=${pkg.version}`) |
link(rel="icon" type="image/png" sizes="96x96" href=`/img/icon/icon-96x96.png?v=${pkg.version}`) |
||||
link(rel="icon" type="image/png" sizes="512x512" href=`/img/icon/${site.domainKey}/icon-512x512.png?v=${pkg.version}`) |
link(rel="icon" type="image/png" sizes="16x16" href=`/img/icon/icon-16x16.png?v=${pkg.version}`) |
||||
link(rel="icon" type="image/png" sizes="384x384" href=`/img/icon/${site.domainKey}/icon-384x384.png?v=${pkg.version}`) |
link(rel="icon" type="image/png" sizes="512x512" href=`/img/icon/icon-512x512.png?v=${pkg.version}`) |
||||
link(rel="icon" type="image/png" sizes="256x256" href=`/img/icon/${site.domainKey}/icon-512x512.png?v=${pkg.version}`) |
link(rel="icon" type="image/png" sizes="384x384" href=`/img/icon/icon-384x384.png?v=${pkg.version}`) |
||||
link(rel="icon" type="image/png" sizes="192x192" href=`/img/icon/${site.domainKey}/icon-192x192.png?v=${pkg.version}`) |
link(rel="icon" type="image/png" sizes="256x256" href=`/img/icon/icon-512x512.png?v=${pkg.version}`) |
||||
|
link(rel="icon" type="image/png" sizes="192x192" href=`/img/icon/icon-192x192.png?v=${pkg.version}`) |
||||
link(rel="manifest" href=`/manifest.json?v=${pkg.version}`) |
link(rel="manifest" href=`/manifest.json?v=${pkg.version}`) |
||||
|
|
||||
meta(name="msapplication-TileColor" content="#f1c52f") |
meta(name="msapplication-TileColor" content="#f1c52f") |
||||
meta(name="msapplication-TileImage" content=`/img/icon/ms-icon-144x144.png?v=${pkg.version}`) |
meta(name="msapplication-TileImage" content=`/img/icon/ms-icon-144x144.png?v=${pkg.version}`) |
||||
meta(name="theme-color" content="#f1c52f") |
meta(name="theme-color" content="#f1c52f") |
@ -0,0 +1,17 @@ |
|||||
|
extends layout/main |
||||
|
block view-content |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-xsmall |
||||
|
.uk-container |
||||
|
.uk-text-large= message |
||||
|
//- if error.stack |
||||
|
//- pre= error.stack |
||||
|
if error && error.statusCode |
||||
|
div.uk-text-small.uk-text-muted status: #{error.statusCode} |
||||
|
|
||||
|
section.uk-section.uk-section-primary.uk-section-xsmall |
||||
|
.uk-container |
||||
|
a(href="/").uk-button.uk-button-default.uk-border-rounded |
||||
|
span.uk-margin-small-right |
||||
|
i.fas.fa-home |
||||
|
span Home |
@ -1,9 +1,7 @@ |
|||||
include layout/main |
extends layout/main |
||||
block view-content |
block view-content |
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
section.uk-section.uk-section-default.uk-section-small |
||||
.uk-container |
.uk-container |
||||
h1= site.name |
h1= site.name |
||||
div= site.description |
div= site.description |
||||
|
|
||||
p The app is doing something else. |
|
@ -0,0 +1,27 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
section.uk-section.uk-section-primary.uk-section-xsmall |
||||
|
.uk-container.uk-text-center |
||||
|
h1 #{site.name} #{otpServiceName} Passcode Required |
||||
|
.uk-text-large A one-time passcode is required to access #{site.name} #{otpServiceName} on this server |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-xsmall |
||||
|
.uk-container |
||||
|
form(method="POST", action="/auth/otp/auth") |
||||
|
input(type="hidden", name="otp-service", value= otpServiceName) |
||||
|
input(type="hidden", name="otp-redirect", value= otpRedirectURL) |
||||
|
.uk-width-1-2.uk-margin-auto |
||||
|
.uk-margin.uk-text-center |
||||
|
label(for="otp-passcode").uk-form-label Enter passcode: |
||||
|
input( |
||||
|
id="otp-passcode", |
||||
|
name="otp-passcode", |
||||
|
type="text", |
||||
|
placeholder="######", |
||||
|
autocomplete="off", |
||||
|
).uk-input.uk-form-large.uk-text-center |
||||
|
.uk-text-muted.uk-text-small Please enter a passcode from your authenticator app for #{site.name} #{otpServiceName}:#{user.username} |
||||
|
|
||||
|
.uk-margin.uk-text-center |
||||
|
button(type="submit").uk-button.uk-button-primary.uk-border-pill Login |
@ -0,0 +1,11 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
section.uk-section.uk-section-primary.uk-section-xsmall |
||||
|
.uk-container |
||||
|
h1 2FA Setup Successful |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-xsmall |
||||
|
.uk-container |
||||
|
p Your account is now enabled with access to #{site.name} #{otpServiceName}. |
||||
|
a(href= otpRedirectURL, title="Continue").uk-button.uk-button-primary.uk-border-pill Continue |
@ -0,0 +1,58 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-xsmall |
||||
|
.uk-container |
||||
|
h1 #{site.name} #{otpServiceName} 2FA Setup |
||||
|
|
||||
|
form(method="POST", action="/auth/otp/enable") |
||||
|
input(type="hidden", name="otp-secret", value= otpTempSecret) |
||||
|
input(type="hidden", name="otp-service", value= otpServiceName) |
||||
|
input(type="hidden", name="otp-redirect", value= otpRedirectURL) |
||||
|
div(uk-grid) |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-auto@s uk-text-center") |
||||
|
.uk-margin |
||||
|
canvas(id="otp-qrcode", width="480", height="480") |
||||
|
.uk-margin |
||||
|
a(href=otpKeyURI, title="Add to Authenticator App").uk-button.uk-button-default.uk-border-pill Add to authenticator |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-expand@s uk-text-center uk-text-left@m") |
||||
|
.uk-margin |
||||
|
p You can scan this QR code using an authenticator app such as #[a(href="https://freeotp.github.io/") FreeOTP], or select "Add to authenticator" if you are using the device with your authenticator app installed. |
||||
|
|
||||
|
p Your authenticator will begin displaying #{otpOptions.digits}-digit authentication codes. Enter one below to enable Two-Factor Authentication (2FA) for #{site.name} #{otpServiceName} (as #{user.username}). |
||||
|
|
||||
|
.uk-text-center |
||||
|
.uk-margin |
||||
|
label(for="otp-passcode").uk-form-label Enter passcode: |
||||
|
br |
||||
|
input( |
||||
|
id="otp-passcode", |
||||
|
name="otp-passcode", |
||||
|
type="text", |
||||
|
placeholder="######", |
||||
|
autocomplete="off", |
||||
|
).uk-input.uk-form-large.uk-text-center.uk-width-1-2 |
||||
|
|
||||
|
.uk-margin |
||||
|
button(type="submit").uk-button.uk-button-primary.uk-border-pill Enable 2FA |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-text-center uk-text-left@m", hidden) |
||||
|
.uk-margin |
||||
|
p Or, if your authenticator doesn't support scanning QR codes, you can enter the OTP configuration information shown here to begin displaying codes: |
||||
|
pre( |
||||
|
style="display: inline-block; border: solid 1px #888888; padding: 8px;" |
||||
|
).uk-border.uk-text-left.uk-margin-remove. |
||||
|
Secret: #{otpTempSecret} |
||||
|
Algorithm: #{otpOptions.algorithm.toUpperCase()} |
||||
|
Step/Period: #{otpOptions.step} |
||||
|
Digits: #{otpOptions.digits} |
||||
|
|
||||
|
block viewjs |
||||
|
script. |
||||
|
window.dtp.keyURI = !{JSON.stringify(otpKeyURI)}; |
||||
|
window.addEventListener('dtp-load', async ( ) => { |
||||
|
const canvas = document.getElementById('otp-qrcode'); |
||||
|
dtp.app.generateOtpQR(canvas, window.dtp.keyURI); |
||||
|
}); |
@ -0,0 +1,39 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
mixin renderProfilePicture (user, options) |
||||
|
- |
||||
|
var iconImageUrl = (user.picture && user.picture.small) ? `/image/${user.picture.small._id}` : '/img/default-member.png'; |
||||
|
options = Object.assign({ |
||||
|
title: user.displayName || user.username, |
||||
|
iconClass: 'sb-xxsmall', |
||||
|
}, options); |
||||
|
|
||||
|
a(href=`/member/${user.username}`, uk-tooltip={ title: `Visit ${user.displayName || user.username}` }).uk-link-reset |
||||
|
img( |
||||
|
src= iconImageUrl, |
||||
|
class= `streamray-profile-picture ${options.iconClass}`, |
||||
|
style= "margin: 0;", |
||||
|
) |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
h1 Block List |
||||
|
|
||||
|
if Array.isArray(blockedUsers) && (blockedUsers.length > 0) |
||||
|
ul.uk-list.uk-list-divider |
||||
|
each member in blockedUsers |
||||
|
li(data-user-id= member._id, uk-grid).uk-grid-small.uk-flex-middle |
||||
|
.uk-width-auto |
||||
|
+renderProfilePicture(member) |
||||
|
.uk-width-auto |
||||
|
a(href=`/member/${member.username}`).uk-link-reset @#{member.username} |
||||
|
.uk-width-expand.uk-text-truncate |
||||
|
a(href=`/member/${member.username}`).uk-link-reset= member.displayName |
||||
|
.uk-width-auto |
||||
|
button( |
||||
|
data-user-id= member._id, |
||||
|
onclick="return dtp.app.unblockUser(event);", |
||||
|
).uk-button.dtp-button-danger.uk-button-small.uk-border-rounded Unblock |
||||
|
else |
||||
|
div You have no blocked users. |
@ -0,0 +1,9 @@ |
|||||
|
include user-icon |
||||
|
mixin renderUserListItem (user, options) |
||||
|
- options = Object.assign({ iconClass: 'sb-xxsmall' }, options); |
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
+renderUserIcon(user, options) |
||||
|
.uk-width-expand |
||||
|
div(uk-tooltip= user.displayName || user.username).streamray-display-name.uk-text-truncate= user.displayName || user.username |
||||
|
div(uk-tooltip= user.username_lc).uk-text-small.streamray-username.uk-text-truncate @#{user.username_lc} |
@ -0,0 +1,12 @@ |
|||||
|
mixin renderProfilePicture (user, options) |
||||
|
- |
||||
|
var iconImageUrl = (user.picture && user.picture.large) ? `/image/${user.picture.large._id}` : '/img/default-member.png'; |
||||
|
options = Object.assign({ |
||||
|
title: user.displayName || user.username, |
||||
|
iconClass: 'sb-xxsmall', |
||||
|
}, options); |
||||
|
|
||||
|
img( |
||||
|
src= iconImageUrl, |
||||
|
class= `streamray-profile-picture ${options.iconClass}`, |
||||
|
) |
@ -0,0 +1,13 @@ |
|||||
|
mixin renderUserIcon (user, options) |
||||
|
- |
||||
|
var iconImageUrl = (user.picture && user.picture.small) ? `/image/${user.picture.small._id || user.picture.small}` : '/img/default-member.png'; |
||||
|
options = Object.assign({ |
||||
|
title: user.displayName || user.username, |
||||
|
iconClass: 'sb-xxsmall', |
||||
|
}, options); |
||||
|
|
||||
|
img( |
||||
|
src= iconImageUrl, |
||||
|
class= `streamray-profile-picture ${options.iconClass}`, |
||||
|
title= options.title, |
||||
|
) |
@ -0,0 +1,8 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container.uk-text-center |
||||
|
h1 Two-Factor Authentication |
||||
|
p You have successfully disabled Two-Factor Authentication. |
||||
|
a(href=`/user/${user._id}/settings?tab=account`).uk-button.dtp-button-default Return to Settings |
@ -0,0 +1,8 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container.uk-text-center |
||||
|
h1 Two-Factor Authentication |
||||
|
p You have successfully enabled Two-Factor Authentication on your account. |
||||
|
a(href=`/user/${user._id}/settings?tab=account`).uk-button.dtp-button-default Return to Settings |
@ -0,0 +1,95 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
include components/profile-picture |
||||
|
include ../handcash/components/public-profile |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
.uk-margin-medium |
||||
|
div(uk-grid).uk-grid-small.uk-flex-middle.no-select |
||||
|
.uk-width-auto |
||||
|
+renderProfilePicture(user, { iconClass: 'sb-small' }) |
||||
|
|
||||
|
.uk-width-expand |
||||
|
h1.uk-margin-remove= user.displayName || user.username || user.email |
||||
|
if user.bio |
||||
|
.markdown-block.uk-text-truncate!= marked.parse(user.bio) |
||||
|
|
||||
|
.uk-margin-medium |
||||
|
h2.sr-only Account Services |
||||
|
div(class="uk-flex-around uk-flex-between@m", uk-grid) |
||||
|
.uk-width-auto |
||||
|
a(href=`/user/${userProfile._id}/settings`).uk-button.dtp-button-primary.uk-border-rounded |
||||
|
span |
||||
|
i.fas.fa-cog |
||||
|
span.uk-margin-small-left Settings |
||||
|
|
||||
|
.uk-width-auto |
||||
|
a(href='/sticker').uk-button.dtp-button-secondary.uk-border-rounded |
||||
|
span |
||||
|
i.far.fa-image |
||||
|
span.uk-margin-small-left Manage Stickers |
||||
|
|
||||
|
.uk-width-auto |
||||
|
a(href=`/user/${userProfile._id}/redeem/channel-pass`).uk-button.dtp-button-secondary.uk-border-rounded |
||||
|
span |
||||
|
i.fas.fa-bullhorn |
||||
|
span.uk-margin-small-left Redeem Pass |
||||
|
|
||||
|
.uk-width-auto |
||||
|
a(href=`/user/${userProfile._id}/block`).uk-button.dtp-button-secondary.uk-border-rounded |
||||
|
span |
||||
|
i.fas.fa-ban |
||||
|
span.uk-margin-small-left Block List |
||||
|
|
||||
|
- |
||||
|
var haveStripe = user.tokens && user.tokens.stripe; |
||||
|
var haveHandCash = handcash && handcash.profile; |
||||
|
|
||||
|
if haveStripe || haveHandCash |
||||
|
.uk-margin-medium |
||||
|
h2.sr-only Payment Services |
||||
|
div(uk-grid).uk-grid-match |
||||
|
if haveStripe |
||||
|
div(class="uk-width-1-1 uk-width-1-2@m") |
||||
|
.uk-card.uk-card-secondary.uk-card-small.uk-flex.uk-flex-column.uk-flex-stretch |
||||
|
.uk-card-header.uk-flex-none |
||||
|
h3.uk-card-title |
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
i.fab.fa-cc-stripe |
||||
|
.uk-width-expand Customer Portal |
||||
|
.uk-card-body.uk-flex-1 |
||||
|
p Access your Stripe Customer Portal to manage your subscription(s), payment methods, and customer profile. |
||||
|
p You may be required to log in or otherwise prove your identity when accessing the Stripe Customer Portal. |
||||
|
p Please email #[a(href="mailto:[email protected]") [email protected]] if you are having a problem here at #{site.name} that isn't related to Stripe or payments. |
||||
|
.uk-card-footer.uk-flex-none |
||||
|
a( |
||||
|
href=`/user/${userProfile._id}/stripe/customer-portal`, |
||||
|
uk-tooltip={ title: 'Manage your Stripe profile and details' }, |
||||
|
).uk-button.dtp-button-default.uk-border-rounded |
||||
|
span Go To Customer Portal |
||||
|
|
||||
|
if haveHandCash |
||||
|
div(class="uk-width-1-1 uk-width-1-2@m") |
||||
|
.uk-card.uk-card-secondary.uk-card-small.uk-flex.uk-flex-column.uk-flex-stretch |
||||
|
.uk-card-header.uk-flex-none |
||||
|
h3.uk-card-title |
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
img(src="/img/payment/handcash-monogram.green.svg", style="height: 1em; width: auto;") |
||||
|
.uk-width-expand HandCash Web Wallet |
||||
|
.uk-card-body.uk-flex-1 |
||||
|
p You have connected your HandCash web wallet with #{site.name} for the easiest and fastest payments possible. |
||||
|
+renderHandcashPublicProfile(handcash.profile.publicProfile) |
||||
|
.uk-card-footer.uk-flex-none |
||||
|
.uk-margin |
||||
|
a( |
||||
|
href="https://market.handcash.io/my-account/balance", |
||||
|
target="_blank", |
||||
|
uk-tooltip={ title: 'Manage your HandCash web wallet' }, |
||||
|
).uk-button.dtp-button-default.uk-border-rounded |
||||
|
span Go To Wallet |
||||
|
|
||||
|
.uk-margin-medium.uk-text-small.uk-text-muted You have used or connected with the payment services shown here. These convenience links help you manage those accounts, your subscriptions, payment methods, profile(s) and other details. |
@ -0,0 +1,20 @@ |
|||||
|
extends ../../layouts/main |
||||
|
block content |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-small |
||||
|
.uk-container |
||||
|
h1 Channel Pass Applied |
||||
|
|
||||
|
.uk-margin |
||||
|
div(uk-grid) |
||||
|
.uk-width-auto |
||||
|
label.uk-form-label Channel |
||||
|
.uk-text-bold= pass.channel.name |
||||
|
.uk-width-auto |
||||
|
label.uk-form-label Expires |
||||
|
.uk-text-bold= dayjs(pass.expires).format('MMM DD, YYYY') |
||||
|
|
||||
|
.uk-margin |
||||
|
div This channel pass lets you enjoy #{pass.channel.name} for free. At the end of the trial, or at any time between now and then, you can upgrade to #[a(href="/membership/plan/premium") Premium]. |
||||
|
|
||||
|
a(href=`/channel/${pass.channel.slug}`).uk-button.dtp-button-primary.uk-border-rounded Visit Channel Now! |
@ -0,0 +1,26 @@ |
|||||
|
extends ../../layouts/main |
||||
|
block content |
||||
|
|
||||
|
include ../../channel/components/list-item |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-small |
||||
|
.uk-container.uk-width-large |
||||
|
|
||||
|
form(method="POST", action=`/user/${userProfile._id}/redeem/channel-pass`).uk-form |
||||
|
.uk-card.uk-card-default.uk-card-small |
||||
|
.uk-card-header |
||||
|
h1.uk-card-title Redeem Channel Pass |
||||
|
|
||||
|
.uk-card-body |
||||
|
if pass && pass.channel |
||||
|
.streamray-recommendation-list.uk-margin |
||||
|
+renderChannelListItem(pass.channel) |
||||
|
.uk-text-lead= pass.name |
||||
|
div= pass.description |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="token").uk-form-label Enter Channel Pass Code |
||||
|
input(id="token", name="token", placeholder="Enter Channel Pass Code", value= query ? query.token : undefined).uk-input |
||||
|
|
||||
|
.uk-card-footer |
||||
|
button(type="submit").uk-button.dtp-button-primary Redeem Code |
@ -0,0 +1,154 @@ |
|||||
|
extends ../layout/main |
||||
|
block vendorcss |
||||
|
link(rel='stylesheet', href=`/cropperjs/cropper.min.css?v=${pkg.version}`) |
||||
|
block vendorjs |
||||
|
script(src=`/cropperjs/cropper.min.js?v=${pkg.version}`) |
||||
|
block view-content |
||||
|
|
||||
|
include ../components/file-upload-image |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
h1 Settings |
||||
|
|
||||
|
- |
||||
|
var tabNames = { |
||||
|
"account": 0, |
||||
|
}; |
||||
|
|
||||
|
ul(uk-tab={ active: tabNames[startTab], animation: false }) |
||||
|
li |
||||
|
a Account |
||||
|
|
||||
|
ul.uk-switcher |
||||
|
//- User account and billing |
||||
|
li |
||||
|
div(uk-grid) |
||||
|
div(class="uk-width-1-1 uk-width-1-3@m") |
||||
|
- |
||||
|
var currentImage = null; |
||||
|
if (user.picture && user.picture.large) { |
||||
|
currentImage = user.picture.large; |
||||
|
} |
||||
|
|
||||
|
.uk-margin |
||||
|
+renderFileUploadImage( |
||||
|
`/user/${user._id}/profile-photo`, |
||||
|
'profile-picture-upload', |
||||
|
'profile-picture-file', |
||||
|
'streamray-profile-picture', |
||||
|
`/img/default-member.png`, |
||||
|
currentImage, |
||||
|
{ aspectRatio: 1 }, |
||||
|
) |
||||
|
|
||||
|
.uk-margin.uk-text-center |
||||
|
if hasOtpAccount |
||||
|
a(href=`/user/${user._id}/otp-disable`).uk-button.uk-button-danger.uk-border-rounded Disable 2FA |
||||
|
else |
||||
|
a(href=`/user/${user._id}/otp-setup`).uk-button.uk-button-secondary.uk-border-rounded Enable 2FA |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-expand@m") |
||||
|
form( |
||||
|
method="POST", |
||||
|
action=`/user/${user._id}/settings`, |
||||
|
autocomplete= "off", |
||||
|
onsubmit="return dtp.app.submitForm(event, 'user account update');", |
||||
|
).uk-form |
||||
|
input(type="hidden", name= csrfTokenAccountSettings.name, value= csrfTokenAccountSettings.token) |
||||
|
|
||||
|
ul(uk-tab, uk-switcher={ connect: '#account-settings-tabs'}) |
||||
|
li |
||||
|
a(href) Profile |
||||
|
li |
||||
|
a(href) Password |
||||
|
li |
||||
|
a(href) Email |
||||
|
if user.flags && user.flags.isModerator |
||||
|
li |
||||
|
a(href) Moderator |
||||
|
|
||||
|
ul#account-settings-tabs.uk-switcher |
||||
|
li |
||||
|
fieldset |
||||
|
legend Profile |
||||
|
.uk-margin |
||||
|
label(for="username").uk-form-label Username |
||||
|
input(id="username", name="username", type="text", placeholder="Enter username", value= userProfile.username).uk-input |
||||
|
.uk-margin |
||||
|
label(for="display-name").uk-form-label Display Name |
||||
|
input(id="display-name", name="displayName", type="text", placeholder="Enter display name", value= userProfile.displayName).uk-input |
||||
|
.uk-margin |
||||
|
label(for="bio").uk-form-label Bio |
||||
|
textarea(id="bio", name="bio", rows="4", placeholder="Enter profile bio").uk-textarea.uk-resize-vertical= userProfile.bio |
||||
|
|
||||
|
li |
||||
|
fieldset |
||||
|
legend Password |
||||
|
.uk-margin |
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-1-2 |
||||
|
.uk-margin |
||||
|
label(for="password").uk-form-label New Password |
||||
|
input(id="password", name="password", type="password", placeholder="Enter new password", autocomplete= "new-password").uk-input |
||||
|
.uk-width-1-2 |
||||
|
.uk-margin |
||||
|
label(for="passwordv").uk-form-label Verify New Password |
||||
|
input(id="passwordv", name="passwordv", type="password", placeholder="Enter new password again", autocomplete= "new-password").uk-input |
||||
|
|
||||
|
li |
||||
|
fieldset |
||||
|
legend Email Preferences |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="email").uk-form-label |
||||
|
span Email Address |
||||
|
if user.flags.isEmailVerified |
||||
|
span (verified) |
||||
|
div(uk-grid).uk-grid-small |
||||
|
div(class="uk-width-1-1 uk-width-expand@s") |
||||
|
.uk-margin-small |
||||
|
input(id="email", name="email", type="email", placeholder="Enter email address", value= userProfile.email).uk-input |
||||
|
if user.flags.isEmailVerified |
||||
|
.uk-text-small.uk-text-muted Changing your email address will un-verify you and send a new verification email. Check your spam folder! |
||||
|
else |
||||
|
.uk-text-small.uk-text-muted Changing your email address will send a new verification email. Check your spam folder! |
||||
|
div(class="uk-width-1-1 uk-width-auto@s") |
||||
|
button(type="button", onclick="return dtp.app.resendWelcomeEmail(event);").uk-button.uk-button-secondary.uk-border-rounded Resend Welcome Email |
||||
|
|
||||
|
.uk-margin |
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
.pretty.p-switch.p-slim |
||||
|
input(id="optin-system", name="optIn.system", type="checkbox", checked= userProfile.optIn ? userProfile.optIn.system : false) |
||||
|
.state.p-success |
||||
|
label(for="optin-system") System Messages |
||||
|
.uk-width-auto |
||||
|
.pretty.p-switch.p-slim |
||||
|
input(id="optin-marketing", name="optIn.marketing", type="checkbox", checked= userProfile.optIn ? userProfile.optIn.marketing : false) |
||||
|
.state.p-success |
||||
|
label(for="optin-marketing") Newsletter |
||||
|
.uk-width-auto |
||||
|
.pretty.p-switch.p-slim |
||||
|
input(id="email-verified", type="checkbox", checked= userProfile.flags ? userProfile.flags.isEmailVerified : false, disabled) |
||||
|
.state.p-success |
||||
|
label(for="email-verified") Email Verified |
||||
|
|
||||
|
if user.flags && user.flags.isModerator |
||||
|
li |
||||
|
fieldset |
||||
|
legend Moderator Preferences |
||||
|
.uk-margin |
||||
|
.pretty.p-switch.p-slim |
||||
|
input(id="moderator-cloaked", name="flags.isCloaked", type="checkbox", checked= userProfile.flags ? userProfile.flags.isCloaked : false) |
||||
|
.state.p-success |
||||
|
label(for="moderator-cloaked") Enable Ghost Mode |
||||
|
|
||||
|
.uk-margin |
||||
|
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Update account settings |
||||
|
|
||||
|
block viewjs |
||||
|
script. |
||||
|
window.addEventListener('dtp-load', async ( ) => { |
||||
|
window.dtp.app.initSettingsView(); |
||||
|
}); |
@ -1,20 +1,31 @@ |
|||||
include ../layout/main |
extends ../layout/main |
||||
block view-content |
block view-content |
||||
|
|
||||
section.uk-section.uk-section-default.uk-section-small |
section.uk-section.uk-section-default.uk-section-small |
||||
.uk-container |
.uk-container |
||||
h1.uk-margin-remove Welcome to #{site.name}! |
|
||||
|
|
||||
p #{site.name} is a real-time communications tool with high quality audio and video, extremely low latency, real-time text messaging, voicemail, and an easy-to-use interface built to remove everything unnecessary and focus on being good at making calls. |
.uk-margin-medium |
||||
|
h1.uk-margin-remove Welcome to #{site.name} |
||||
|
.uk-text-bold= site.description |
||||
|
|
||||
div(uk-grid) |
.uk-margin-medium |
||||
div(class="uk-width-1-1 uk-width-1-2@m") |
div(uk-grid).uk-grid-divider |
||||
.uk-margin-small |
div(class="uk-width-1-1 uk-width-1-2@m") |
||||
.uk-text-large New here? |
.uk-margin-small |
||||
div Start receiving and attending calls for free by creating a User Account. |
.uk-text-large New here? |
||||
a(href="/welcome/signup").uk-button.uk-button-primary.uk-button-large.uk-border-rounded Sign Up |
div Start receiving and attending calls for free by creating a User Account. |
||||
div(class="uk-width-1-1 uk-width-1-2@m") |
a(href="/welcome/signup").uk-button.uk-button-primary.uk-border-rounded Sign Up |
||||
.uk-margin-small |
div(class="uk-width-1-1 uk-width-1-2@m") |
||||
.uk-text-large Returning member? |
.uk-margin-small |
||||
div Sign into your User Account with your username and password. |
.uk-text-large Returning member? |
||||
a(href="/welcome/login").uk-button.uk-button-secondary.uk-button-large.uk-border-rounded Login |
div Sign into your User Account with your username and password. |
||||
|
a(href="/welcome/login").uk-button.uk-button-secondary.uk-border-rounded Login |
||||
|
|
||||
|
h2 About #{site.name} |
||||
|
|
||||
|
p #{site.name} is a real-time communications tool with high quality audio and video, extremely low latency, instant messaging, voicemail, and an easy-to-use interface. It removes and avoids everything unnecessary while focusing on being excellent at making calls and helping people stay in touch. |
||||
|
|
||||
|
p There is no app to download from a store. #{site.name} is a Progressive Web App (PWA) that runs in your browser. It can be installed as a desktop or mobile app if you like, and provides an even sleeker interface if you do. It can do a better job delivering notifications when installed as an app. |
||||
|
|
||||
|
.uk-margin |
||||
|
.uk-text-small.uk-text-muted Anonymous use is not supported. A user account in good standing is required to use the app. #{site.name} is not free for hosting group and conference calls. Free members can make and receive calls, but can't create group/conference calls and don't have voicemail services. |
@ -0,0 +1,25 @@ |
|||||
|
extends ../layout/main |
||||
|
block view-content |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-small |
||||
|
.uk-container |
||||
|
h1= site.name |
||||
|
|
||||
|
form(method="POST", action="/auth/login").uk-form |
||||
|
input(type="hidden", name= csrfLogin.name, value= csrfLogin.token) |
||||
|
.uk-card.uk-card-secondary.uk-card-small |
||||
|
.uk-card-header |
||||
|
h1.uk-card-title Account Login |
||||
|
|
||||
|
.uk-card-body |
||||
|
.uk-margin |
||||
|
label(for="username").uk-form-label Username |
||||
|
input(id="username", name="username", type="text", required, placeholder="Enter username or email address").uk-input |
||||
|
.uk-margin |
||||
|
label(for="password").uk-form-label Password |
||||
|
input(id="password", name="password", type="password", required, placeholder="Enter password").uk-input |
||||
|
|
||||
|
.uk-card-footer |
||||
|
div(uk-grid).uk-flex-right |
||||
|
.uk-width-auto |
||||
|
button(type="submit").uk-button.uk-button-primary Login |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 7.3 KiB |
After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 547 B |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 7.3 KiB |
After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 547 B |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.9 KiB |