wekan/models/users.js
2018-10-09 21:16:47 +03:00

898 lines
24 KiB
JavaScript

// Sandstorm context is detected using the METEOR_SETTINGS environment variable
// in the package definition.
const isSandstorm = Meteor.settings && Meteor.settings.public &&
Meteor.settings.public.sandstorm;
Users = Meteor.users;
Users.attachSchema(new SimpleSchema({
username: {
type: String,
optional: true,
autoValue() { // eslint-disable-line consistent-return
if (this.isInsert && !this.isSet) {
const name = this.field('profile.fullname');
if (name.isSet) {
return name.value.toLowerCase().replace(/\s/g, '');
}
}
},
},
emails: {
type: [Object],
optional: true,
},
'emails.$.address': {
type: String,
regEx: SimpleSchema.RegEx.Email,
},
'emails.$.verified': {
type: Boolean,
},
createdAt: {
type: Date,
autoValue() { // eslint-disable-line consistent-return
if (this.isInsert) {
return new Date();
} else {
this.unset();
}
},
},
profile: {
type: Object,
optional: true,
autoValue() { // eslint-disable-line consistent-return
if (this.isInsert && !this.isSet) {
return {
boardView: 'board-view-lists',
};
}
},
},
'profile.avatarUrl': {
type: String,
optional: true,
},
'profile.emailBuffer': {
type: [String],
optional: true,
},
'profile.fullname': {
type: String,
optional: true,
},
'profile.hiddenSystemMessages': {
type: Boolean,
optional: true,
},
'profile.initials': {
type: String,
optional: true,
},
'profile.invitedBoards': {
type: [String],
optional: true,
},
'profile.language': {
type: String,
optional: true,
},
'profile.notifications': {
type: [String],
optional: true,
},
'profile.showCardsCountAt': {
type: Number,
optional: true,
},
'profile.starredBoards': {
type: [String],
optional: true,
},
'profile.tags': {
type: [String],
optional: true,
},
'profile.icode': {
type: String,
optional: true,
},
'profile.boardView': {
type: String,
optional: true,
allowedValues: [
'board-view-lists',
'board-view-swimlanes',
'board-view-cal',
],
},
services: {
type: Object,
optional: true,
blackbox: true,
},
heartbeat: {
type: Date,
optional: true,
},
isAdmin: {
type: Boolean,
optional: true,
},
createdThroughApi: {
type: Boolean,
optional: true,
},
loginDisabled: {
type: Boolean,
optional: true,
},
'authenticationMethod': {
type: String,
optional: false,
defaultValue: 'password',
},
}));
Users.allow({
update(userId) {
const user = Users.findOne(userId);
return user && Meteor.user().isAdmin;
},
});
// Search a user in the complete server database by its name or username. This
// is used for instance to add a new user to a board.
const searchInFields = ['username', 'profile.fullname'];
Users.initEasySearch(searchInFields, {
use: 'mongo-db',
returnFields: [...searchInFields, 'profile.avatarUrl'],
});
if (Meteor.isClient) {
Users.helpers({
isBoardMember() {
const board = Boards.findOne(Session.get('currentBoard'));
return board && board.hasMember(this._id);
},
isNotNoComments() {
const board = Boards.findOne(Session.get('currentBoard'));
return board && board.hasMember(this._id) && !board.hasNoComments(this._id);
},
isNoComments() {
const board = Boards.findOne(Session.get('currentBoard'));
return board && board.hasNoComments(this._id);
},
isNotCommentOnly() {
const board = Boards.findOne(Session.get('currentBoard'));
return board && board.hasMember(this._id) && !board.hasCommentOnly(this._id);
},
isCommentOnly() {
const board = Boards.findOne(Session.get('currentBoard'));
return board && board.hasCommentOnly(this._id);
},
isBoardAdmin() {
const board = Boards.findOne(Session.get('currentBoard'));
return board && board.hasAdmin(this._id);
},
});
}
Users.helpers({
boards() {
return Boards.find({ 'members.userId': this._id });
},
starredBoards() {
const {starredBoards = []} = this.profile;
return Boards.find({archived: false, _id: {$in: starredBoards}});
},
hasStarred(boardId) {
const {starredBoards = []} = this.profile;
return _.contains(starredBoards, boardId);
},
invitedBoards() {
const {invitedBoards = []} = this.profile;
return Boards.find({archived: false, _id: {$in: invitedBoards}});
},
isInvitedTo(boardId) {
const {invitedBoards = []} = this.profile;
return _.contains(invitedBoards, boardId);
},
hasTag(tag) {
const {tags = []} = this.profile;
return _.contains(tags, tag);
},
hasNotification(activityId) {
const {notifications = []} = this.profile;
return _.contains(notifications, activityId);
},
hasHiddenSystemMessages() {
const profile = this.profile || {};
return profile.hiddenSystemMessages || false;
},
getEmailBuffer() {
const {emailBuffer = []} = this.profile;
return emailBuffer;
},
getInitials() {
const profile = this.profile || {};
if (profile.initials)
return profile.initials;
else if (profile.fullname) {
return profile.fullname.split(/\s+/).reduce((memo, word) => {
return memo + word[0];
}, '').toUpperCase();
} else {
return this.username[0].toUpperCase();
}
},
getLimitToShowCardsCount() {
const profile = this.profile || {};
return profile.showCardsCountAt;
},
getName() {
const profile = this.profile || {};
return profile.fullname || this.username;
},
getLanguage() {
const profile = this.profile || {};
return profile.language || 'en';
},
});
Users.mutations({
toggleBoardStar(boardId) {
const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet';
return {
[queryKind]: {
'profile.starredBoards': boardId,
},
};
},
addInvite(boardId) {
return {
$addToSet: {
'profile.invitedBoards': boardId,
},
};
},
removeInvite(boardId) {
return {
$pull: {
'profile.invitedBoards': boardId,
},
};
},
addTag(tag) {
return {
$addToSet: {
'profile.tags': tag,
},
};
},
removeTag(tag) {
return {
$pull: {
'profile.tags': tag,
},
};
},
toggleTag(tag) {
if (this.hasTag(tag))
this.removeTag(tag);
else
this.addTag(tag);
},
toggleSystem(value = false) {
return {
$set: {
'profile.hiddenSystemMessages': !value,
},
};
},
addNotification(activityId) {
return {
$addToSet: {
'profile.notifications': activityId,
},
};
},
removeNotification(activityId) {
return {
$pull: {
'profile.notifications': activityId,
},
};
},
addEmailBuffer(text) {
return {
$addToSet: {
'profile.emailBuffer': text,
},
};
},
clearEmailBuffer() {
return {
$set: {
'profile.emailBuffer': [],
},
};
},
setAvatarUrl(avatarUrl) {
return {$set: {'profile.avatarUrl': avatarUrl}};
},
setShowCardsCountAt(limit) {
return {$set: {'profile.showCardsCountAt': limit}};
},
setBoardView(view) {
return {
$set : {
'profile.boardView': view,
},
};
},
});
Meteor.methods({
setUsername(username, userId) {
check(username, String);
const nUsersWithUsername = Users.find({username}).count();
if (nUsersWithUsername > 0) {
throw new Meteor.Error('username-already-taken');
} else {
Users.update(userId, {$set: {username}});
}
},
toggleSystemMessages() {
const user = Meteor.user();
user.toggleSystem(user.hasHiddenSystemMessages());
},
changeLimitToShowCardsCount(limit) {
check(limit, Number);
Meteor.user().setShowCardsCountAt(limit);
},
setEmail(email, userId) {
check(email, String);
const existingUser = Users.findOne({'emails.address': email}, {fields: {_id: 1}});
if (existingUser) {
throw new Meteor.Error('email-already-taken');
} else {
Users.update(userId, {
$set: {
emails: [{
address: email,
verified: false,
}],
},
});
}
},
setUsernameAndEmail(username, email, userId) {
check(username, String);
check(email, String);
check(userId, String);
Meteor.call('setUsername', username, userId);
Meteor.call('setEmail', email, userId);
},
setPassword(newPassword, userId) {
check(userId, String);
check(newPassword, String);
if(Meteor.user().isAdmin){
Accounts.setPassword(userId, newPassword);
}
},
});
if (Meteor.isServer) {
Meteor.methods({
// we accept userId, username, email
inviteUserToBoard(username, boardId) {
check(username, String);
check(boardId, String);
const inviter = Meteor.user();
const board = Boards.findOne(boardId);
const allowInvite = inviter &&
board &&
board.members &&
_.contains(_.pluck(board.members, 'userId'), inviter._id) &&
_.where(board.members, {userId: inviter._id})[0].isActive &&
_.where(board.members, {userId: inviter._id})[0].isAdmin;
if (!allowInvite) throw new Meteor.Error('error-board-notAMember');
this.unblock();
const posAt = username.indexOf('@');
let user = null;
if (posAt >= 0) {
user = Users.findOne({emails: {$elemMatch: {address: username}}});
} else {
user = Users.findOne(username) || Users.findOne({username});
}
if (user) {
if (user._id === inviter._id) throw new Meteor.Error('error-user-notAllowSelf');
} else {
if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist');
if (Settings.findOne().disableRegistration) throw new Meteor.Error('error-user-notCreated');
// Set in lowercase email before creating account
const email = username.toLowerCase();
username = email.substring(0, posAt);
const newUserId = Accounts.createUser({username, email});
if (!newUserId) throw new Meteor.Error('error-user-notCreated');
// assume new user speak same language with inviter
if (inviter.profile && inviter.profile.language) {
Users.update(newUserId, {
$set: {
'profile.language': inviter.profile.language,
},
});
}
Accounts.sendEnrollmentEmail(newUserId);
user = Users.findOne(newUserId);
}
board.addMember(user._id);
user.addInvite(boardId);
try {
const params = {
user: user.username,
inviter: inviter.username,
board: board.title,
url: board.absoluteUrl(),
};
const lang = user.getLanguage();
Email.send({
to: user.emails[0].address.toLowerCase(),
from: Accounts.emailTemplates.from,
subject: TAPi18n.__('email-invite-subject', params, lang),
text: TAPi18n.__('email-invite-text', params, lang),
});
} catch (e) {
throw new Meteor.Error('email-fail', e.message);
}
return {username: user.username, email: user.emails[0].address};
},
});
Accounts.onCreateUser((options, user) => {
const userCount = Users.find().count();
if (!isSandstorm && userCount === 0) {
user.isAdmin = true;
return user;
}
if (user.services.oidc) {
const email = user.services.oidc.email.toLowerCase();
user.username = user.services.oidc.username;
user.emails = [{ address: email, verified: true }];
const initials = user.services.oidc.fullname.match(/\b[a-zA-Z]/g).join('').toUpperCase();
user.profile = { initials, fullname: user.services.oidc.fullname };
user.authenticationMethod = 'oauth2';
// see if any existing user has this email address or username, otherwise create new
const existingUser = Meteor.users.findOne({$or: [{'emails.address': email}, {'username':user.username}]});
if (!existingUser)
return user;
// copy across new service info
const service = _.keys(user.services)[0];
existingUser.services[service] = user.services[service];
existingUser.emails = user.emails;
existingUser.username = user.username;
existingUser.profile = user.profile;
existingUser.authenticationMethod = user.authenticationMethod;
Meteor.users.remove({_id: existingUser._id}); // remove existing record
return existingUser;
}
if (options.from === 'admin') {
user.createdThroughApi = true;
return user;
}
const disableRegistration = Settings.findOne().disableRegistration;
// If ldap, bypass the inviation code if the self registration isn't allowed.
// TODO : pay attention if ldap field in the user model change to another content ex : ldap field to connection_type
if (options.ldap || !disableRegistration) {
user.authenticationMethod = 'ldap';
return user;
}
if (!options || !options.profile) {
throw new Meteor.Error('error-invitation-code-blank', 'The invitation code is required');
}
const invitationCode = InvitationCodes.findOne({
code: options.profile.invitationcode,
email: options.email,
valid: true,
});
if (!invitationCode) {
throw new Meteor.Error('error-invitation-code-not-exist', 'The invitation code doesn\'t exist');
} else {
user.profile = {icode: options.profile.invitationcode};
user.profile.boardView = 'board-view-lists';
// Deletes the invitation code after the user was created successfully.
setTimeout(Meteor.bindEnvironment(() => {
InvitationCodes.remove({'_id': invitationCode._id});
}), 200);
return user;
}
});
}
if (Meteor.isServer) {
// Let mongoDB ensure username unicity
Meteor.startup(() => {
Users._collection._ensureIndex({
username: 1,
}, {unique: true});
});
// Each board document contains the de-normalized number of users that have
// starred it. If the user star or unstar a board, we need to update this
// counter.
// We need to run this code on the server only, otherwise the incrementation
// will be done twice.
Users.after.update(function (userId, user, fieldNames) {
// The `starredBoards` list is hosted on the `profile` field. If this
// field hasn't been modificated we don't need to run this hook.
if (!_.contains(fieldNames, 'profile'))
return;
// To calculate a diff of board starred ids, we get both the previous
// and the newly board ids list
function getStarredBoardsIds(doc) {
return doc.profile && doc.profile.starredBoards;
}
const oldIds = getStarredBoardsIds(this.previous);
const newIds = getStarredBoardsIds(user);
// The _.difference(a, b) method returns the values from a that are not in
// b. We use it to find deleted and newly inserted ids by using it in one
// direction and then in the other.
function incrementBoards(boardsIds, inc) {
boardsIds.forEach((boardId) => {
Boards.update(boardId, {$inc: {stars: inc}});
});
}
incrementBoards(_.difference(oldIds, newIds), -1);
incrementBoards(_.difference(newIds, oldIds), +1);
});
const fakeUserId = new Meteor.EnvironmentVariable();
const getUserId = CollectionHooks.getUserId;
CollectionHooks.getUserId = () => {
return fakeUserId.get() || getUserId();
};
if (!isSandstorm) {
Users.after.insert((userId, doc) => {
const fakeUser = {
extendAutoValueContext: {
userId: doc._id,
},
};
fakeUserId.withValue(doc._id, () => {
// Insert the Welcome Board
Boards.insert({
title: TAPi18n.__('welcome-board'),
permission: 'private',
}, fakeUser, (err, boardId) => {
Swimlanes.insert({
title: TAPi18n.__('welcome-swimlane'),
boardId,
sort: 1,
}, fakeUser);
['welcome-list1', 'welcome-list2'].forEach((title, titleIndex) => {
Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
});
});
});
});
}
Users.after.insert((userId, doc) => {
if (doc.createdThroughApi) {
// The admin user should be able to create a user despite disabling registration because
// it is two different things (registration and creation).
// So, when a new user is created via the api (only admin user can do that) one must avoid
// the disableRegistration check.
// Issue : https://github.com/wekan/wekan/issues/1232
// PR : https://github.com/wekan/wekan/pull/1251
Users.update(doc._id, {$set: {createdThroughApi: ''}});
return;
}
//invite user to corresponding boards
const disableRegistration = Settings.findOne().disableRegistration;
// If ldap, bypass the inviation code if the self registration isn't allowed.
// TODO : pay attention if ldap field in the user model change to another content ex : ldap field to connection_type
if (doc.authenticationMethod !== 'ldap' && disableRegistration) {
const invitationCode = InvitationCodes.findOne({code: doc.profile.icode, valid: true});
if (!invitationCode) {
throw new Meteor.Error('error-invitation-code-not-exist');
} else {
invitationCode.boardsToBeInvited.forEach((boardId) => {
const board = Boards.findOne(boardId);
board.addMember(doc._id);
});
if (!doc.profile) {
doc.profile = {};
}
doc.profile.invitedBoards = invitationCode.boardsToBeInvited;
Users.update(doc._id, {$set: {profile: doc.profile}});
InvitationCodes.update(invitationCode._id, {$set: {valid: false}});
}
}
});
}
// USERS REST API
if (Meteor.isServer) {
// Middleware which checks that API is enabled.
JsonRoutes.Middleware.use(function (req, res, next) {
const api = req.url.search('api');
if (api === 1 && process.env.WITH_API === 'true' || api === -1){
return next();
}
else {
res.writeHead(301, {Location: '/'});
return res.end();
}
});
JsonRoutes.add('GET', '/api/user', function(req, res) {
try {
Authentication.checkLoggedIn(req.userId);
const data = Meteor.users.findOne({ _id: req.userId});
delete data.services;
JsonRoutes.sendResult(res, {
code: 200,
data,
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('GET', '/api/users', function (req, res) {
try {
Authentication.checkUserId(req.userId);
JsonRoutes.sendResult(res, {
code: 200,
data: Meteor.users.find({}).map(function (doc) {
return { _id: doc._id, username: doc.username };
}),
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('GET', '/api/users/:id', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.id;
JsonRoutes.sendResult(res, {
code: 200,
data: Meteor.users.findOne({ _id: id }),
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('PUT', '/api/users/:id', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.id;
const action = req.body.action;
let data = Meteor.users.findOne({ _id: id });
if (data !== undefined) {
if (action === 'takeOwnership') {
data = Boards.find({
'members.userId': id,
'members.isAdmin': true,
}).map(function(board) {
if (board.hasMember(req.userId)) {
board.removeMember(req.userId);
}
board.changeOwnership(id, req.userId);
return {
_id: board._id,
title: board.title,
};
});
} else {
if ((action === 'disableLogin') && (id !== req.userId)) {
Users.update({ _id: id }, { $set: { loginDisabled: true, 'services.resume.loginTokens': '' } });
} else if (action === 'enableLogin') {
Users.update({ _id: id }, { $set: { loginDisabled: '' } });
}
data = Meteor.users.findOne({ _id: id });
}
}
JsonRoutes.sendResult(res, {
code: 200,
data,
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/add', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const userId = req.params.userId;
const boardId = req.params.boardId;
const action = req.body.action;
const {isAdmin, isNoComments, isCommentOnly} = req.body;
let data = Meteor.users.findOne({ _id: userId });
if (data !== undefined) {
if (action === 'add') {
data = Boards.find({
_id: boardId,
}).map(function(board) {
if (!board.hasMember(userId)) {
board.addMember(userId);
function isTrue(data){
return data.toLowerCase() === 'true';
}
board.setMemberPermission(userId, isTrue(isAdmin), isTrue(isNoComments), isTrue(isCommentOnly), userId);
}
return {
_id: board._id,
title: board.title,
};
});
}
}
JsonRoutes.sendResult(res, {
code: 200,
data: query,
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/remove', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const userId = req.params.userId;
const boardId = req.params.boardId;
const action = req.body.action;
let data = Meteor.users.findOne({ _id: userId });
if (data !== undefined) {
if (action === 'remove') {
data = Boards.find({
_id: boardId,
}).map(function(board) {
if (board.hasMember(userId)) {
board.removeMember(userId);
}
return {
_id: board._id,
title: board.title,
};
});
}
}
JsonRoutes.sendResult(res, {
code: 200,
data: query,
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('POST', '/api/users/', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = Accounts.createUser({
username: req.body.username,
email: req.body.email,
password: req.body.password,
from: 'admin',
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
JsonRoutes.add('DELETE', '/api/users/:id', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.id;
Meteor.users.remove({ _id: id });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
}
catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
}