1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Server: Move admin pages under /admin (#6006)

This commit is contained in:
Laurent 2022-01-14 10:14:43 +00:00 committed by GitHub
parent 3fcdeb08d9
commit ca7e68ba4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1037 additions and 484 deletions

View File

@ -16,4 +16,6 @@ module.exports = {
'jest-expect-message',
`${__dirname}/jest.setup.js`,
],
bail: true,
};

View File

@ -61,7 +61,8 @@ ul li {
list-style-type: disc;
}
ul.pagination-list li {
ul.pagination-list li,
ul.menu-list li {
list-style-type: none;
}

View File

@ -161,7 +161,7 @@ async function main() {
});
} catch (anotherError) {
ctx.response.set('Content-Type', 'application/json');
ctx.body = JSON.stringify({ error: error.message });
ctx.body = JSON.stringify({ error: `${error.message} (Check the server log for more information)` });
}
} else {
ctx.response.set('Content-Type', 'application/json');

View File

@ -120,6 +120,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
stripe: stripeConfigFromEnv(stripePublicConfig, env),
port: appPort,
baseUrl,
adminBaseUrl: `${baseUrl}/admin`,
showErrorStackTraces: env.ERROR_STACK_TRACES,
apiBaseUrl,
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,

View File

@ -4,7 +4,7 @@ import { isView, View } from '../services/MustacheService';
import config from '../config';
import { userIp } from '../utils/requestUtils';
import { createCsrfTag } from '../utils/csrf';
import { getImpersonatorAdminSessionId } from '../routes/index/utils/users/impersonate';
import { getImpersonatorAdminSessionId } from '../routes/admin/utils/users/impersonate';
export default async function(ctx: AppContext) {
const requestStartTime = Date.now();
@ -20,6 +20,7 @@ export default async function(ctx: AppContext) {
const view = responseObject as View;
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
currentUrl: ctx.URL,
notifications: ctx.joplin.notifications || [],
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
owner: ctx.joplin.owner,

View File

@ -0,0 +1,14 @@
import { _ } from '@joplin/lib/locale';
import defaultView from '../../utils/defaultView';
import Router from '../../utils/Router';
import { SubPath } from '../../utils/routeUtils';
import { AppContext, RouteType } from '../../utils/types';
const router = new Router(RouteType.Web);
router.get('admin/dashboard', async (_path: SubPath, _ctx: AppContext) => {
const view = defaultView('admin/dashboard', _('Admin dashboard'));
return view;
});
export default router;

View File

@ -16,7 +16,7 @@ const prettyCron = require('prettycron');
const router: Router = new Router(RouteType.Web);
router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
router.post('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();
@ -52,7 +52,7 @@ router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
return redirect(ctx, makeUrl(UrlType.Tasks));
});
router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();
@ -126,7 +126,7 @@ router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
};
return {
...defaultView('tasks', 'Tasks'),
...defaultView('admin/tasks', 'Tasks'),
content: {
itemTable: makeTableView(table),
postUrl: makeUrl(UrlType.Tasks),

View File

@ -8,13 +8,13 @@ import { yesOrNo } from '../../utils/strings';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time';
import { userDeletionsUrl, userUrl } from '../../utils/urlUtils';
import { adminUserDeletionsUrl, userUrl } from '../../utils/urlUtils';
import { createCsrfTag } from '../../utils/csrf';
import { bodyFields } from '../../utils/requestUtils';
const router: Router = new Router(RouteType.Web);
router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();
@ -26,7 +26,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
console.info(page);
const table: Table = {
baseUrl: userDeletionsUrl(),
baseUrl: adminUserDeletionsUrl(),
requestQuery: ctx.query,
pageCount: page.page_count,
pagination,
@ -110,7 +110,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
}),
};
const view = defaultView('user_deletions', 'User deletions');
const view = defaultView('admin/user_deletions', 'User deletions');
view.content = {
userDeletionTable: makeTableView(table),
postUrl: makeUrl(UrlType.UserDeletions),
@ -124,7 +124,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
throw new ErrorMethodNotAllowed();
});
router.post('user_deletions', async (_path: SubPath, ctx: AppContext) => {
router.post('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();

View File

@ -0,0 +1,183 @@
import { User } from '../../services/database/types';
import routeHandler from '../../middleware/routeHandler';
import { execRequest } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
import { ErrorForbidden } from '../../utils/errors';
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
password = password === null ? uuidgen() : password;
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: '/admin/users/new',
body: {
email: email,
password: password,
password2: password,
post_button: true,
...props,
},
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: url ? url : '/admin/users',
body: {
...user,
post_button: true,
},
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
describe('admin/users', function() {
beforeAll(async () => {
await beforeAllDb('admin/users');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a new user', async function() {
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, 'test@example.com', password, {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
expect(newUser.max_item_size).toBe(null);
expect(newUser.must_set_password).toBe(0);
const userModel = models().user();
const userFromModel: User = await userModel.load(newUser.id);
expect(!!userFromModel.password).toBe(true);
expect(userFromModel.password === password).toBe(false); // Password has been hashed
});
test('should create a user with null properties if they are not explicitly set', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com');
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.max_item_size).toBe(null);
expect(newUser.can_share_folder).toBe(null);
expect(newUser.can_share_note).toBe(null);
expect(newUser.max_total_item_size).toBe(null);
});
test('should ask user to set password if not set on creation', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com', '', {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.must_set_password).toBe(1);
expect(!!newUser.password).toBe(true);
});
test('should format the email when saving it', async function() {
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, email, password);
const loggedInUser = await models().user().login(email, password);
expect(!!loggedInUser).toBe(true);
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
});
test('should not create anything if user creation fail', async function() {
const { session } = await createUserAndSession(1, true);
const userModel = models().user();
const password = uuidgen();
await postUser(session.id, 'test@example.com', password);
const beforeUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(2);
try {
await postUser(session.id, 'test@example.com', password);
} catch {
// Ignore
}
const afterUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(afterUserCount);
});
test('should list users', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1, true);
const { user: user2 } = await createUserAndSession(2, false);
const result = await execRequest(session1.id, 'GET', 'admin/users');
expect(result).toContain(user1.email);
expect(result).toContain(user2.email);
});
test('should delete sessions when changing password', async function() {
const { user, session, password } = await createUserAndSession(1);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
expect(await models().session().count()).toBe(4);
await patchUser(session.id, {
id: user.id,
email: 'changed@example.com',
password: 'hunter11hunter22hunter33',
password2: 'hunter11hunter22hunter33',
}, '/admin/users/me');
const sessions = await models().session().all();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(session.id);
});
test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
// admin user cannot make themselves a non-admin
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
// cannot delete own user
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `admin/users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
});
});

View File

@ -0,0 +1,311 @@
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
import uuidgen from '../../utils/uuidgen';
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl } from '../../utils/urlUtils';
import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime, Hour } from '../../utils/time';
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel';
import { _ } from '@joplin/lib/locale';
export interface CheckRepeatPasswordInput {
password: string;
password2: string;
}
export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required: boolean): string {
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
return fields.password;
} else {
if (required) throw new ErrorUnprocessableEntity('Password is required');
}
return '';
}
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function makeUser(isNew: boolean, fields: any): User {
const user: User = {};
if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name;
if ('is_admin' in fields) user.is_admin = fields.is_admin;
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
if ('account_type' in fields) user.account_type = Number(fields.account_type);
const password = checkRepeatPassword(fields, false);
if (password) user.password = password;
if (!isNew) user.id = fields.id;
if (isNew) {
user.must_set_password = user.password ? 0 : 1;
user.password = user.password ? user.password : uuidgen();
}
return user;
}
function defaultUser(): User {
return {};
}
function userIsNew(path: SubPath): boolean {
return path.id === 'new';
}
function userIsMe(path: SubPath): boolean {
return path.id === 'me';
}
const router = new Router(RouteType.Web);
router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.joplin.models.user();
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
const users = await userModel.all();
users.sort((u1: User, u2: User) => {
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
if (u1.full_name && !u2.full_name) return +1;
if (!u1.full_name && u2.full_name) return -1;
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
});
const view: View = defaultView('admin/users', _('Users'));
view.content = {
users: users.map(user => {
return {
...user,
url: adminUserUrl(user.id),
displayName: user.full_name ? user.full_name : '(not set)',
formattedItemMaxSize: formatMaxItemSize(user),
formattedTotalSize: formatTotalSize(user),
formattedMaxTotalSize: formatMaxTotalSize(user),
formattedTotalSizePercent: formatTotalSizePercent(user),
totalSizeClass: totalSizeClass(user),
formattedAccountType: accountTypeToString(user.account_type),
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
rowClassName: user.enabled ? '' : 'is-disabled',
};
}),
};
return view;
});
router.get('admin/users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
const owner = ctx.joplin.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const models = ctx.joplin.models;
const userId = userIsMe(path) ? owner.id : path.id;
user = !isNew ? user || await models.user().load(userId) : user;
if (isNew && !user) user = defaultUser();
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
let postUrl = '';
if (isNew) {
postUrl = adminUserUrl('new');
} else if (isMe) {
postUrl = adminUserUrl('me');
} else {
postUrl = adminUserUrl(user.id);
}
interface UserFlagView extends UserFlag {
message: string;
}
const userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
return {
...f,
message: userFlagToString(f),
};
});
userFlagViews.sort((a, b) => {
return a.created_time < b.created_time ? +1 : -1;
});
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
const view: View = defaultView('admin/user', _('Profile'));
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? _('Create user') : _('Update profile');
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDisableButton = !isNew && owner.id !== user.id && user.enabled;
view.content.csrfTag = await createCsrfTag(ctx);
if (subscription) {
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
view.content.subscription = subscription;
view.content.showManageSubscription = !isNew;
view.content.showUpdateSubscriptionBasic = !isNew && user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
}
view.content.showImpersonateButton = !isNew && user.enabled && user.id !== owner.id;
view.content.showRestoreButton = !isNew && !user.enabled;
view.content.showScheduleDeletionButton = !isNew && !isScheduledForDeletion;
view.content.showResetPasswordButton = !isNew && user.enabled;
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
view.content.hasFlags = !!userFlagViews.length;
view.content.userFlagViews = userFlagViews;
view.content.stripePortalUrl = stripePortalUrl();
view.content.pageTitle = view.content.buttonTitle;
view.jsFiles.push('zxcvbn');
view.cssFiles.push('index/user');
if (config().accountTypesEnabled) {
view.content.showAccountTypes = true;
view.content.accountTypes = accountTypeOptions().map((o: any) => {
o.selected = user.account_type === o.value;
return o;
});
}
return view;
});
router.alias(HttpMethod.POST, 'admin/users/:id', 'admin/users');
interface FormFields {
id: Uuid;
post_button: string;
disable_button: string;
restore_button: string;
cancel_subscription_button: string;
send_account_confirmation_email: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
impersonate_button: string;
stop_impersonate_button: string;
delete_user_flags: string;
schedule_deletion_button: string;
}
router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
let user: User = {};
const owner = ctx.joplin.owner;
let userId = userIsMe(path) ? owner.id : path.id;
try {
const body = await formParse(ctx.req);
const fields = body.fields as FormFields;
const isNew = userIsNew(path);
if (userIsMe(path)) fields.id = userId;
user = makeUser(isNew, fields);
const models = ctx.joplin.models;
if (fields.post_button) {
const userToSave: User = models.user().fromApiInput(user);
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
if (isNew) {
const savedUser = await models.user().save(userToSave);
userId = savedUser.id;
} else {
await models.user().save(userToSave, { isNew: false });
// When changing the password, we also clear all session IDs for
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
}
} else if (fields.stop_impersonate_button) {
await stopImpersonating(ctx);
return redirect(ctx, config().baseUrl);
} else if (fields.disable_button || fields.restore_button) {
const user = await models.user().load(path.id);
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
} else if (fields.send_account_confirmation_email) {
const user = await models.user().load(path.id);
await models.user().save({ id: user.id, must_set_password: 1 });
await models.user().sendAccountConfirmationEmail(user);
} else if (fields.impersonate_button) {
await startImpersonating(ctx, userId);
return redirect(ctx, config().baseUrl);
} else if (fields.cancel_subscription_button) {
await cancelSubscriptionByUserId(models, userId);
} else if (fields.update_subscription_basic_button) {
await updateSubscriptionType(models, userId, AccountType.Basic);
} else if (fields.update_subscription_pro_button) {
await updateSubscriptionType(models, userId, AccountType.Pro);
} else if (fields.schedule_deletion_button) {
const deletionDate = Date.now() + 24 * Hour;
await models.userDeletion().add(userId, deletionDate, {
processAccount: true,
processData: true,
});
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${adminUserDeletionsUrl()})`);
} else if (fields.delete_user_flags) {
const userFlagTypes: UserFlagType[] = [];
for (const key of Object.keys(fields)) {
if (key.startsWith('user_flag_')) {
const type = Number(key.substr(10));
userFlagTypes.push(type);
}
}
await models.userFlag().removeMulti(userId, userFlagTypes);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, adminUserUrl(userIsMe(path) ? '/me' : `/${userId}`));
} catch (error) {
error.message = `Error: Your changes were not saved: ${error.message}`;
if (error instanceof ErrorForbidden) throw error;
const endPoint = router.findEndPoint(HttpMethod.GET, 'admin/users/:id');
return endPoint.handler(path, ctx, user, error);
}
});
export default router;

View File

@ -1,10 +1,10 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_home', function() {
describe('index/home', function() {
beforeAll(async () => {
await beforeAllDb('index_home');
await beforeAllDb('index/home');
});
afterAll(async () => {

View File

@ -7,14 +7,14 @@ import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError, expectThrow } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
export async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
password = password === null ? uuidgen() : password;
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: '/users/new',
url: '/admin/users/new',
body: {
email: email,
password: password,
@ -30,7 +30,7 @@ export async function postUser(sessionId: string, email: string, password: strin
return context.response.body;
}
export async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
@ -48,7 +48,7 @@ export async function patchUser(sessionId: string, user: any, url: string = ''):
return context.response.body;
}
export async function getUserHtml(sessionId: string, userId: string): Promise<string> {
async function getUserHtml(sessionId: string, userId: string): Promise<string> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
@ -76,53 +76,6 @@ describe('index/users', function() {
await beforeEachDb();
});
test('should create a new user', async function() {
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, 'test@example.com', password, {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
expect(newUser.max_item_size).toBe(null);
expect(newUser.must_set_password).toBe(0);
const userModel = models().user();
const userFromModel: User = await userModel.load(newUser.id);
expect(!!userFromModel.password).toBe(true);
expect(userFromModel.password === password).toBe(false); // Password has been hashed
});
test('should create a user with null properties if they are not explicitly set', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com');
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.max_item_size).toBe(null);
expect(newUser.can_share_folder).toBe(null);
expect(newUser.can_share_note).toBe(null);
expect(newUser.max_total_item_size).toBe(null);
});
test('should ask user to set password if not set on creation', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com', '', {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.must_set_password).toBe(1);
expect(!!newUser.password).toBe(true);
});
test('new user should be able to login', async function() {
const { session } = await createUserAndSession(1, true);
@ -133,39 +86,6 @@ describe('index/users', function() {
expect(loggedInUser.email).toBe('test@example.com');
});
test('should format the email when saving it', async function() {
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, email, password);
const loggedInUser = await models().user().login(email, password);
expect(!!loggedInUser).toBe(true);
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
});
test('should not create anything if user creation fail', async function() {
const { session } = await createUserAndSession(1, true);
const userModel = models().user();
const password = uuidgen();
await postUser(session.id, 'test@example.com', password);
const beforeUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(2);
try {
await postUser(session.id, 'test@example.com', password);
} catch {
// Ignore
}
const afterUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(afterUserCount);
});
test('should change user properties', async function() {
const { user, session } = await createUserAndSession(1, false);
@ -198,15 +118,6 @@ describe('index/users', function() {
expect((doc.querySelector('input[name=email]') as any).value).toBe('user1@localhost');
});
test('should list users', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1, true);
const { user: user2 } = await createUserAndSession(2, false);
const result = await execRequest(session1.id, 'GET', 'users');
expect(result).toContain(user1.email);
expect(result).toContain(user2.email);
});
test('should allow user to set a password for new accounts', async function() {
let user1 = await models().user().save({
email: 'user1@localhost',
@ -366,33 +277,31 @@ describe('index/users', function() {
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
});
test('should delete sessions when changing password', async function() {
const { user, session, password } = await createUserAndSession(1);
test('should not change non-whitelisted properties', async () => {
const { user: user1, session: session1 } = await createUserAndSession(2, false);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
expect(await models().session().count()).toBe(4);
await patchUser(session.id, {
id: user.id,
email: 'changed@example.com',
password: 'hunter11hunter22hunter33',
password2: 'hunter11hunter22hunter33',
}, '/users/me');
const sessions = await models().session().all();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(session.id);
await patchUser(session1.id, {
id: user1.id,
is_admin: 1,
max_item_size: 555,
max_total_item_size: 5555,
can_share_folder: 1,
can_upload: 0,
});
const reloadedUser1 = await models().user().load(user1.id);
expect(reloadedUser1.is_admin).toBe(0);
expect(reloadedUser1.max_item_size).toBe(null);
expect(reloadedUser1.max_total_item_size).toBe(null);
expect(reloadedUser1.can_share_folder).toBe(null);
expect(reloadedUser1.can_upload).toBe(1);
});
test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
const { user: user1, session: session1 } = await createUserAndSession(2, false);
const { user: admin } = await createUserAndSession(1, true);
const { session: session1 } = await createUserAndSession(2, false);
// non-admin cannot list users
await expectHttpError(async () => execRequest(session1.id, 'GET', 'users'), ErrorForbidden.httpCode);
await expectHttpError(async () => execRequest(session1.id, 'GET', 'admin/users'), ErrorForbidden.httpCode);
// non-admin user cannot view another user
await expectHttpError(async () => execRequest(session1.id, 'GET', `users/${admin.id}`), ErrorForbidden.httpCode);
@ -402,30 +311,6 @@ describe('index/users', function() {
// non-admin user cannot update another user
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, email: 'cantdothateither@example.com' }), ErrorForbidden.httpCode);
// non-admin user cannot make themself an admin
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, is_admin: 1 }), ErrorForbidden.httpCode);
// admin user cannot make themselves a non-admin
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
// only admins can delete users
// Note: Disabled because the entire code is skipped if it's not an admin
// await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
// cannot delete own user
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
// non-admin cannot change max_item_size
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_item_size: 1000 }), ErrorForbidden.httpCode);
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_total_item_size: 1000 }), ErrorForbidden.httpCode);
// non-admin cannot change can_share_folder
await models().user().save({ id: user1.id, can_share_folder: 0 });
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_share_folder: 1 }), ErrorForbidden.httpCode);
// non-admin cannot change non-whitelisted properties
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_upload: 0 }), ErrorForbidden.httpCode);
});

View File

@ -4,24 +4,21 @@ import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types';
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
import { User, UserFlag, Uuid } from '../../services/database/types';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
import uuidgen from '../../utils/uuidgen';
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
import { confirmUrl, stripePortalUrl, userDeletionsUrl } from '../../utils/urlUtils';
import { cancelSubscriptionByUserId, updateCustomerEmail, updateSubscriptionType } from '../../utils/stripe';
import { AccountType, accountTypeOptions } from '../../models/UserModel';
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
import { updateCustomerEmail } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime, Hour } from '../../utils/time';
import { formatDateTime } from '../../utils/time';
import { cookieSet } from '../../utils/cookies';
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel';
import { stopImpersonating } from '../admin/utils/users/impersonate';
import { _ } from '@joplin/lib/locale';
export interface CheckRepeatPasswordInput {
password: string;
@ -39,120 +36,40 @@ export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required:
return '';
}
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function makeUser(isNew: boolean, fields: any): User {
function makeUser(userId: Uuid, fields: any): User {
const user: User = {};
if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name;
if ('is_admin' in fields) user.is_admin = fields.is_admin;
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
if ('account_type' in fields) user.account_type = Number(fields.account_type);
const password = checkRepeatPassword(fields, false);
if (password) user.password = password;
if (!isNew) user.id = fields.id;
if (isNew) {
user.must_set_password = user.password ? 0 : 1;
user.password = user.password ? user.password : uuidgen();
}
user.id = userId;
return user;
}
function defaultUser(): User {
return {};
}
function userIsNew(path: SubPath): boolean {
return path.id === 'new';
}
function userIsMe(path: SubPath): boolean {
return path.id === 'me';
}
const router = new Router(RouteType.Web);
router.get('users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.joplin.models.user();
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
const users = await userModel.all();
users.sort((u1: User, u2: User) => {
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
if (u1.full_name && !u2.full_name) return +1;
if (!u1.full_name && u2.full_name) return -1;
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
});
const view: View = defaultView('users', 'Users');
view.content = {
users: users.map(user => {
return {
...user,
displayName: user.full_name ? user.full_name : '(not set)',
formattedItemMaxSize: formatMaxItemSize(user),
formattedTotalSize: formatTotalSize(user),
formattedMaxTotalSize: formatMaxTotalSize(user),
formattedTotalSizePercent: formatTotalSizePercent(user),
totalSizeClass: totalSizeClass(user),
formattedAccountType: accountTypeToString(user.account_type),
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
rowClassName: user.enabled ? '' : 'is-disabled',
};
}),
userDeletionUrl: userDeletionsUrl(),
};
return view;
});
router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User = null, error: any = null) => {
const owner = ctx.joplin.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const models = ctx.joplin.models;
const userId = userIsMe(path) ? owner.id : path.id;
if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
user = !isNew ? user || await models.user().load(userId) : null;
if (isNew && !user) user = defaultUser();
const models = ctx.joplin.models;
const userId = owner.id;
const user = await models.user().load(userId);
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
let postUrl = '';
if (isNew) {
postUrl = `${config().baseUrl}/users/new`;
} else if (isMe) {
postUrl = `${config().baseUrl}/users/me`;
} else {
postUrl = `${config().baseUrl}/users/${user.id}`;
}
const postUrl = `${config().baseUrl}/users/me`;
interface UserFlagView extends UserFlag {
message: string;
}
let userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
let userFlagViews: UserFlagView[] = (await models.userFlag().allByUserId(user.id)).map(f => {
return {
...f,
message: userFlagToString(f),
@ -165,35 +82,25 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
if (!owner.is_admin) userFlagViews = [];
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
const subscription = await ctx.joplin.models.subscription().byUserId(userId);
const view: View = defaultView('user', 'Profile');
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
view.content.user = formUser ? formUser : user;
view.content.buttonTitle = _('Update profile');
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
view.content.csrfTag = await createCsrfTag(ctx);
if (subscription) {
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
view.content.subscription = subscription;
view.content.showManageSubscription = !isNew;
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
view.content.showUpdateSubscriptionBasic = user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = user.account_type !== AccountType.Pro;
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
}
view.content.showImpersonateButton = !isNew && !!owner.is_admin && user.enabled && user.id !== owner.id;
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
view.content.showScheduleDeletionButton = !isNew && !!owner.is_admin && !isScheduledForDeletion;
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
view.content.hasFlags = !!userFlagViews.length;
view.content.userFlagViews = userFlagViews;
view.content.stripePortalUrl = stripePortalUrl();
@ -303,98 +210,50 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
interface FormFields {
id: Uuid;
post_button: string;
disable_button: string;
restore_button: string;
cancel_subscription_button: string;
send_account_confirmation_email: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
// user_cancel_subscription_button: string;
impersonate_button: string;
stop_impersonate_button: string;
delete_user_flags: string;
schedule_deletion_button: string;
}
router.post('users', async (path: SubPath, ctx: AppContext) => {
let user: User = {};
const owner = ctx.joplin.owner;
const userId = userIsMe(path) ? owner.id : path.id;
if (path.id && path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
const models = ctx.joplin.models;
let user: User = null;
try {
const body = await formParse(ctx.req);
const fields = body.fields as FormFields;
const isNew = userIsNew(path);
if (userIsMe(path)) fields.id = userId;
user = makeUser(isNew, fields);
const models = ctx.joplin.models;
if (fields.id && fields.id !== owner.id) throw new ErrorForbidden();
user = makeUser(owner.id, fields);
if (fields.post_button) {
const userToSave: User = models.user().fromApiInput(user);
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
await models.user().checkIfAllowed(owner, AclAction.Update, userToSave);
if (isNew) {
await models.user().save(userToSave);
} else {
if (userToSave.email && !owner.is_admin) {
await models.user().initiateEmailChange(userId, userToSave.email);
delete userToSave.email;
}
await models.user().save(userToSave, { isNew: false });
// When changing the password, we also clear all session IDs for
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
if (userToSave.email && userToSave.email !== owner.email) {
await models.user().initiateEmailChange(owner.id, userToSave.email);
delete userToSave.email;
}
await models.user().save(userToSave, { isNew: false });
// When changing the password, we also clear all session IDs for
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
} else if (fields.stop_impersonate_button) {
await stopImpersonating(ctx);
return redirect(ctx, config().baseUrl);
} else if (owner.is_admin) {
if (fields.disable_button || fields.restore_button) {
const user = await models.user().load(path.id);
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
} else if (fields.send_account_confirmation_email) {
const user = await models.user().load(path.id);
await models.user().save({ id: user.id, must_set_password: 1 });
await models.user().sendAccountConfirmationEmail(user);
} else if (fields.impersonate_button) {
await startImpersonating(ctx, userId);
return redirect(ctx, config().baseUrl);
} else if (fields.cancel_subscription_button) {
await cancelSubscriptionByUserId(models, userId);
} else if (fields.update_subscription_basic_button) {
await updateSubscriptionType(models, userId, AccountType.Basic);
} else if (fields.update_subscription_pro_button) {
await updateSubscriptionType(models, userId, AccountType.Pro);
} else if (fields.schedule_deletion_button) {
const deletionDate = Date.now() + 24 * Hour;
await models.userDeletion().add(userId, deletionDate, {
processAccount: true,
processData: true,
});
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${userDeletionsUrl()})`);
} else if (fields.delete_user_flags) {
const userFlagTypes: UserFlagType[] = [];
for (const key of Object.keys(fields)) {
if (key.startsWith('user_flag_')) {
const type = Number(key.substr(10));
userFlagTypes.push(type);
}
}
await models.userFlag().removeMulti(userId, userFlagTypes);
} else {
throw new Error('Invalid form button');
}
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : `/${userId}`}`);
return redirect(ctx, `${config().baseUrl}/users/me`);
} catch (error) {
error.message = `Error: Your changes were not saved: ${error.message}`;
if (error instanceof ErrorForbidden) throw error;

View File

@ -5,12 +5,17 @@ import apiBatchItems from './api/batch_items';
import apiDebug from './api/debug';
import apiEvents from './api/events';
import apiItems from './api/items';
import apiLocks from './api/locks';
import apiPing from './api/ping';
import apiSessions from './api/sessions';
import apiShares from './api/shares';
import apiShareUsers from './api/share_users';
import apiUsers from './api/users';
import apiLocks from './api/locks';
import adminDashboard from './admin/dashboard';
import adminTasks from './admin/tasks';
import adminUserDeletions from './admin/user_deletions';
import adminUsers from './admin/users';
import indexChanges from './index/changes';
import indexHelp from './index/help';
@ -24,44 +29,45 @@ import indexPrivacy from './index/privacy';
import indexShares from './index/shares';
import indexSignup from './index/signup';
import indexStripe from './index/stripe';
import indexTasks from './index/tasks';
import indexTerms from './index/terms';
import indexUpgrade from './index/upgrade';
import indexUsers from './index/users';
import indexUserDeletions from './index/user_deletions';
import defaultRoute from './default';
const routes: Routers = {
'api/batch': apiBatch,
'api/batch_items': apiBatchItems,
'api/batch': apiBatch,
'api/debug': apiDebug,
'api/events': apiEvents,
'api/items': apiItems,
'api/locks': apiLocks,
'api/ping': apiPing,
'api/sessions': apiSessions,
'api/share_users': apiShareUsers,
'api/shares': apiShares,
'api/users': apiUsers,
'api/locks': apiLocks,
'admin/dashboard': adminDashboard,
'admin/tasks': adminTasks,
'admin/user_deletions': adminUserDeletions,
'admin/users': adminUsers,
'changes': indexChanges,
'help': indexHelp,
'home': indexHome,
'items': indexItems,
'password': indexPassword,
'login': indexLogin,
'logout': indexLogout,
'notifications': indexNotifications,
'signup': indexSignup,
'password': indexPassword,
'privacy': indexPrivacy,
'shares': indexShares,
'users': indexUsers,
'signup': indexSignup,
'stripe': indexStripe,
'terms': indexTerms,
'privacy': indexPrivacy,
'upgrade': indexUpgrade,
'help': indexHelp,
'tasks': indexTasks,
'user_deletions': indexUserDeletions,
'users': indexUsers,
'': defaultRoute,
};

View File

@ -9,6 +9,19 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
import MarkdownIt = require('markdown-it');
import { headerAnchor } from '@joplin/renderer';
import { _ } from '@joplin/lib/locale';
import { adminDashboardUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
import { URL } from 'url';
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
export interface MenuItem {
title: string;
url?: string;
children?: MenuItem[];
selected?: boolean;
icon?: string;
selectedCondition?: MenuItemSelectedCondition;
}
export interface RenderOptions {
partials?: any;
@ -48,6 +61,10 @@ interface GlobalParams {
impersonatorAdminSessionId?: string;
csrfTag?: string;
s?: Record<string, string>; // List of translatable strings
isAdminPage?: boolean;
adminMenu?: MenuItem[];
navbarMenu?: MenuItem[];
currentUrl?: URL;
}
export function isView(o: any): boolean {
@ -95,6 +112,88 @@ export default class MustacheService {
return `${config().layoutDir}/${name}.mustache`;
}
private setSelectedMenu(selectedUrl: URL, menuItems: MenuItem[]) {
if (!selectedUrl) return;
if (!menuItems) return;
const url = stripOffQueryParameters(selectedUrl.href);
for (const menuItem of menuItems) {
if (menuItem.url) {
if (menuItem.selectedCondition) {
menuItem.selected = menuItem.selectedCondition(selectedUrl);
} else {
menuItem.selected = url === menuItem.url;
}
}
this.setSelectedMenu(selectedUrl, menuItem.children);
}
}
private makeAdminMenu(selectedUrl: URL): MenuItem[] {
const output: MenuItem[] = [
{
title: _('General'),
children: [
{
title: _('Dashboard'),
url: adminDashboardUrl(),
},
{
title: _('Users'),
url: adminUsersUrl(),
},
{
title: _('User deletions'),
url: adminUserDeletionsUrl(),
},
{
title: _('Tasks'),
url: adminTasksUrl(),
},
],
},
];
this.setSelectedMenu(selectedUrl, output);
return output;
}
private makeNavbar(selectedUrl: URL, isAdmin: boolean): MenuItem[] {
let output: MenuItem[] = [
{
title: _('Home'),
url: homeUrl(),
},
];
if (isAdmin) {
output = output.concat([
{
title: _('Items'),
url: itemsUrl(),
},
{
title: _('Logs'),
url: changesUrl(),
},
{
title: _('Admin'),
url: adminDashboardUrl(),
icon: 'fas fa-hammer',
selectedCondition: (selectedUrl: URL) => {
return selectedUrl.pathname.startsWith('/admin/') || selectedUrl.pathname === '/admin';
},
},
]);
}
this.setSelectedMenu(selectedUrl, output);
return output;
}
private get defaultLayoutOptions(): GlobalParams {
return {
baseUrl: config().baseUrl,
@ -187,7 +286,10 @@ export default class MustacheService {
globalParams = {
...this.defaultLayoutOptions,
...globalParams,
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
isAdminPage: view.path.startsWith('/admin/'),
s: {
home: _('Home'),
users: _('Users'),
@ -196,6 +298,7 @@ export default class MustacheService {
tasks: _('Tasks'),
help: _('Help'),
logout: _('Logout'),
admin: _('Admin'),
},
};

View File

@ -2,9 +2,11 @@ import { View } from '../services/MustacheService';
// Populate a View object with some good defaults.
export default function(name: string, title: string): View {
const pathPrefix = name.startsWith('admin/') ? '' : 'index/';
return {
name: name,
path: `index/${name}`,
path: `${pathPrefix}/${name}`,
content: {},
navbar: true,
title: title,

View File

@ -1,6 +1,7 @@
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
import { ItemAddressingType } from '../services/database/types';
import { RouteType } from './types';
import { expectThrow } from './testing/testUtils';
describe('routeUtils', function() {
@ -76,6 +77,9 @@ describe('routeUtils', function() {
const actual = findMatchingRoute(path, routes);
expect(actual).toEqual(expected);
}
await expectThrow(async () => findMatchingRoute('help', routes));
await expectThrow(async () => findMatchingRoute('api/users/123', routes));
});
it('should split an item path', async function() {

View File

@ -223,6 +223,10 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
// - The ID: "SOME_ID"
// - The link: "content"
export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
// Enforce that path starts with "/" because if it doesn't, the function
// will return strange but valid results.
if (path.length && path[0] !== '/') throw new Error(`Expected path to start with "/": ${path}`);
const splittedPath = path.split('/');
// Because the path starts with "/", we remove the first element, which is

View File

@ -24,6 +24,7 @@ import uuidgen from '../uuidgen';
import { createCsrfToken } from '../csrf';
import { cookieSet } from '../cookies';
import { parseEnv } from '../../env';
import { URL } from 'url';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@ -218,7 +219,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
query: req.query,
method: req.method,
redirect: () => {},
URL: { origin: config().baseUrl },
URL: new URL(config().baseUrl), // origin
};
if (options.sessionId) {

View File

@ -144,6 +144,7 @@ export interface Config {
tempDir: string;
baseUrl: string;
apiBaseUrl: string;
adminBaseUrl: string;
userContentBaseUrl: string;
joplinAppBaseUrl: string;
signupEnabled: boolean;

View File

@ -14,6 +14,14 @@ export function setQueryParameters(url: string, query: any): string {
return u.toString();
}
export function stripOffQueryParameters(url: string): string {
const s = url.split('?');
if (s.length <= 1) return url;
s.pop();
return s.join('?');
}
export function resetPasswordUrl(token: string): string {
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
}
@ -42,14 +50,38 @@ export function homeUrl(): string {
return `${config().baseUrl}/home`;
}
export function itemsUrl(): string {
return `${config().baseUrl}/items`;
}
export function changesUrl(): string {
return `${config().baseUrl}/changes`;
}
export function loginUrl(): string {
return `${config().baseUrl}/login`;
}
export function userDeletionsUrl(): string {
return `${config().baseUrl}/user_deletions`;
export function adminUserDeletionsUrl(): string {
return `${config().adminBaseUrl}/user_deletions`;
}
export function userUrl(userId: Uuid): string {
return `${config().baseUrl}/users/${userId}`;
}
export function adminDashboardUrl(): string {
return `${config().adminBaseUrl}/dashboard`;
}
export function adminUsersUrl() {
return `${config().adminBaseUrl}/users`;
}
export function adminUserUrl(userId: string) {
return `${config().adminBaseUrl}/users/${userId}`;
}
export function adminTasksUrl() {
return `${config().adminBaseUrl}/tasks`;
}

View File

@ -0,0 +1,4 @@
<h2 class="title">{{global.appName}} admin dashboard</h2>
<div class="block readable-block">
<p class="block">This is the admin dashboard. Please select an option on the left.</p>
</div>

View File

@ -0,0 +1,170 @@
<h1 class="title">{{pageTitle}}</h1>
<form id="user_form" action="{{{postUrl}}}" method="POST" class="block">
<div class="block">
{{> errorBanner}}
{{{csrfTag}}}
<input type="hidden" name="id" value="{{user.id}}"/>
<input type="hidden" name="is_new" value="{{isNew}}"/>
<div class="field">
<label class="label">Full name</label>
<div class="control">
<input class="input" type="text" name="full_name" value="{{user.full_name}}"/>
</div>
</div>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" name="email" value="{{user.email}}"/>
</div>
</div>
{{#showAccountTypes}}
<div class="field">
<label class="label">Account type</label>
<div class="select">
<select name="account_type">
{{#accountTypes}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/accountTypes}}
</select>
</div>
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
</div>
{{/showAccountTypes}}
<div class="field">
<label class="label">Max item size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Max total size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Can share notebook</label>
<div class="select">
<select name="can_share_folder">
{{#canShareFolderOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canShareFolderOptions}}
</select>
</div>
</div>
<div class="field">
<label class="label">Can upload</label>
<div class="select">
<select name="can_upload">
{{#canUploadOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canUploadOptions}}
</select>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input id="password" class="input" type="password" name="password" autocomplete="new-password"/>
</div>
<p id="password_strength" class="help"></p>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2" autocomplete="new-password"/>
</div>
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
</div>
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{#showImpersonateButton}}
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
{{/showImpersonateButton}}
{{#showResetPasswordButton}}
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
{{/showResetPasswordButton}}
{{#showDisableButton}}
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
{{/showDisableButton}}
{{#showRestoreButton}}
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
{{/showRestoreButton}}
{{#showScheduleDeletionButton}}
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
{{/showScheduleDeletionButton}}
</div>
</div>
{{#subscription}}
<h1 class="title">Your subscription</h1>
<div class="block">
<div class="control block">
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
{{#showUpdateSubscriptionBasic}}
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
{{/showUpdateSubscriptionBasic}}
{{#showUpdateSubscriptionPro}}
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
{{/showUpdateSubscriptionPro}}
</div>
</div>
{{/subscription}}
</form>
{{#hasFlags}}
<div class="content user-flags block">
<h1 class="title">Flags</h1>
<form action="{{{postUrl}}}" method="POST">
{{{csrfTag}}}
{{#userFlagViews}}
<ul>
<li><label class="checkbox"><input type="checkbox" name="user_flag_{{type}}"> {{message}}</label></li>
</ul>
{{/userFlagViews}}
<input type="submit" name="delete_user_flags" class="button is-warning" value="Delete selected flags" />
<p class="help">Note: normally it should not be needed to manually delete a flag because that's automatically handled by the system. So if it's necessary it means there's a bug that should be fixed.</p>
</form>
</div>
{{/hasFlags}}
<script>
$(() => {
document.getElementById("user_form").addEventListener('submit', function(event) {
if (event.submitter.getAttribute('name') === 'disable_button') {
const ok = confirm('Disable this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'restore_button') {
const ok = confirm('Restore this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
const ok = confirm('Downgrade to Basic subscription?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
const ok = confirm('Upgrade to Pro subscription?');
if (!ok) event.preventDefault();
}
});
setupPasswordStrengthHandler();
});
</script>

View File

@ -0,0 +1,60 @@
<div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
</div>
<table class="table">
<thead>
<tr>
<th>Full name</th>
<th>Email</th>
<th>Account</th>
<th>Max Item Size</th>
<th>Total Size</th>
<th>Max Total Size</th>
<th>Can share</th>
</tr>
</thead>
<tbody>
{{#users}}
<tr class="{{rowClassName}}">
<td><a href="{{{url}}}">{{displayName}}</a></td>
<td>{{email}}</td>
<td>{{formattedAccountType}}</td>
<td>{{formattedItemMaxSize}}</td>
<td class="{{totalSizeClass}}">{{formattedTotalSize}} ({{formattedTotalSizePercent}})</td>
<td>{{formattedMaxTotalSize}}</td>
<td>{{formattedCanShareFolder}}</td>
</tr>
{{/users}}
</tbody>
</table>
<div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
</div>
<script>
$(() => {
function toggleDisabled() {
if ($('.hide-disabled').length) {
$('.hide-disabled').addClass('show-disabled');
$('.hide-disabled').removeClass('hide-disabled');
$('.show-disabled').text('Show disabled');
$('table tr.is-disabled').hide();
} else {
$('.show-disabled').addClass('hide-disabled');
$('.show-disabled').removeClass('show-disabled');
$('.hide-disabled').text('Hide disabled');
$('table tr.is-disabled').show();
}
}
toggleDisabled();
$('.toggle-disabled-button').click(() => {
toggleDisabled();
});
});
</script>

View File

@ -20,58 +20,6 @@
</div>
</div>
{{#global.owner.is_admin}}
{{#showAccountTypes}}
<div class="field">
<label class="label">Account type</label>
<div class="select">
<select name="account_type">
{{#accountTypes}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/accountTypes}}
</select>
</div>
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
</div>
{{/showAccountTypes}}
<div class="field">
<label class="label">Max item size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Max total size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Can share notebook</label>
<div class="select">
<select name="can_share_folder">
{{#canShareFolderOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canShareFolderOptions}}
</select>
</div>
</div>
<div class="field">
<label class="label">Can upload</label>
<div class="select">
<select name="can_upload">
{{#canUploadOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canUploadOptions}}
</select>
</div>
</div>
{{/global.owner.is_admin}}
<div class="field">
<label class="label">Password</label>
<div class="control">
@ -85,28 +33,10 @@
<div class="control">
<input class="input" type="password" name="password2" autocomplete="new-password"/>
</div>
{{#global.owner.is_admin}}
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
{{/global.owner.is_admin}}
</div>
</div>
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{#showImpersonateButton}}
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
{{/showImpersonateButton}}
{{#showResetPasswordButton}}
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
{{/showResetPasswordButton}}
{{#showDisableButton}}
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
{{/showDisableButton}}
{{#showRestoreButton}}
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
{{/showRestoreButton}}
{{#showScheduleDeletionButton}}
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
{{/showScheduleDeletionButton}}
</div>
</div>
@ -114,33 +44,17 @@
<h1 class="title">Your subscription</h1>
<div class="block">
{{#global.owner.is_admin}}
{{#showUpdateSubscriptionPro}}
<div class="control block">
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
{{#showUpdateSubscriptionBasic}}
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
{{/showUpdateSubscriptionBasic}}
{{#showUpdateSubscriptionPro}}
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
{{/showUpdateSubscriptionPro}}
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
</div>
{{/global.owner.is_admin}}
{{^global.owner.is_admin}}
{{#showUpdateSubscriptionPro}}
<div class="control block">
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
</div>
{{/showUpdateSubscriptionPro}}
{{#showManageSubscription}}
<div class="control block">
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
</div>
{{/showManageSubscription}}
{{/global.owner.is_admin}}
{{/showUpdateSubscriptionPro}}
<div class="control block">
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
</div>
</div>
{{/subscription}}
@ -164,28 +78,6 @@
<script>
$(() => {
document.getElementById("user_form").addEventListener('submit', function(event) {
if (event.submitter.getAttribute('name') === 'disable_button') {
const ok = confirm('Disable this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'restore_button') {
const ok = confirm('Restore this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
const ok = confirm('Downgrade to Basic subscription?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
const ok = confirm('Upgrade to Pro subscription?');
if (!ok) event.preventDefault();
}
});
setupPasswordStrengthHandler();
});
</script>

View File

@ -24,7 +24,14 @@
<main class="main">
<div class="container">
{{> notifications}}
{{{contentHtml}}}
{{#global.isAdminPage}}
{{> adminLayout}}
{{/global.isAdminPage}}
{{^global.isAdminPage}}
{{{contentHtml}}}
{{/global.isAdminPage}}
</div>
</main>
{{> footer}}

View File

@ -0,0 +1,20 @@
<div style="display: flex; flex-direction: row;">
<div style="display: flex; margin-right: 3rem; background-color: #f5f5f5; padding: 1.5rem;">
<aside class="menu">
{{#global.adminMenu}}
<p class="menu-label">{{title}}</p>
<ul class="menu-list">
{{#children}}
<li><a href="{{{url}}}" class="{{#selected}}is-active{{/selected}}">{{title}}</a></li>
{{/children}}
</ul>
{{/global.adminMenu}}
</aside>
</div>
<div style="display: flex; flex: 1;">
<div>
{{{contentHtml}}}
</div>
</div>
</div>

View File

@ -10,19 +10,9 @@
{{#global.owner}}
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">{{global.s.home}}</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">{{global.s.users}}</a>
{{/global.owner.is_admin}}
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">{{global.s.items}}</a>
{{/global.owner.is_admin}}
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">{{global.s.log}}</a>
{{/global.owner.is_admin}}
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/tasks">{{global.s.tasks}}</a>
{{/global.owner.is_admin}}
{{#global.navbarMenu}}
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i>&nbsp;&nbsp;{{/icon}}{{title}}</a>
{{/global.navbarMenu}}
</div>
<div class="navbar-end">
{{#global.isJoplinCloud}}