diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js index 471352566..3f3a0a20c 100644 --- a/packages/server/jest.config.js +++ b/packages/server/jest.config.js @@ -16,4 +16,6 @@ module.exports = { 'jest-expect-message', `${__dirname}/jest.setup.js`, ], + + bail: true, }; diff --git a/packages/server/public/css/main.css b/packages/server/public/css/main.css index 3b673fcfb..9e82d57a0 100644 --- a/packages/server/public/css/main.css +++ b/packages/server/public/css/main.css @@ -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; } diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 19d9793e1..750d4c27e 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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'); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index cb537b62e..629daba39 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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, diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts index 0136af72f..667237727 100644 --- a/packages/server/src/middleware/routeHandler.ts +++ b/packages/server/src/middleware/routeHandler.ts @@ -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, diff --git a/packages/server/src/routes/admin/dashboard.ts b/packages/server/src/routes/admin/dashboard.ts new file mode 100644 index 000000000..08a9741ac --- /dev/null +++ b/packages/server/src/routes/admin/dashboard.ts @@ -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; diff --git a/packages/server/src/routes/index/tasks.ts b/packages/server/src/routes/admin/tasks.ts similarity index 94% rename from packages/server/src/routes/index/tasks.ts rename to packages/server/src/routes/admin/tasks.ts index 8e2b694b4..ec894e586 100644 --- a/packages/server/src/routes/index/tasks.ts +++ b/packages/server/src/routes/admin/tasks.ts @@ -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), diff --git a/packages/server/src/routes/index/user_deletions.ts b/packages/server/src/routes/admin/user_deletions.ts similarity index 91% rename from packages/server/src/routes/index/user_deletions.ts rename to packages/server/src/routes/admin/user_deletions.ts index d6e179a4d..fa04c8d37 100644 --- a/packages/server/src/routes/index/user_deletions.ts +++ b/packages/server/src/routes/admin/user_deletions.ts @@ -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(); diff --git a/packages/server/src/routes/admin/users.test.ts b/packages/server/src/routes/admin/users.test.ts new file mode 100644 index 000000000..c2b445a0c --- /dev/null +++ b/packages/server/src/routes/admin/users.test.ts @@ -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 { + 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 { + 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); + }); + +}); diff --git a/packages/server/src/routes/admin/users.ts b/packages/server/src/routes/admin/users.ts new file mode 100644 index 000000000..28a5a22ba --- /dev/null +++ b/packages/server/src/routes/admin/users.ts @@ -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; diff --git a/packages/server/src/routes/index/utils/users/impersonate.test.ts b/packages/server/src/routes/admin/utils/users/impersonate.test.ts similarity index 100% rename from packages/server/src/routes/index/utils/users/impersonate.test.ts rename to packages/server/src/routes/admin/utils/users/impersonate.test.ts diff --git a/packages/server/src/routes/index/utils/users/impersonate.ts b/packages/server/src/routes/admin/utils/users/impersonate.ts similarity index 100% rename from packages/server/src/routes/index/utils/users/impersonate.ts rename to packages/server/src/routes/admin/utils/users/impersonate.ts diff --git a/packages/server/src/routes/index/home.test.ts b/packages/server/src/routes/index/home.test.ts index 0348f3623..f8428d0b9 100644 --- a/packages/server/src/routes/index/home.test.ts +++ b/packages/server/src/routes/index/home.test.ts @@ -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 () => { diff --git a/packages/server/src/routes/index/users.test.ts b/packages/server/src/routes/index/users.test.ts index 73679b84f..e5111d91c 100644 --- a/packages/server/src/routes/index/users.test.ts +++ b/packages/server/src/routes/index/users.test.ts @@ -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 { +async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise { 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 { +async function patchUser(sessionId: string, user: any, url: string = ''): Promise { 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 { +async function getUserHtml(sessionId: string, userId: string): Promise { 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); }); diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index 11cd3899b..b3043e072 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -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; diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 2b97c6f74..8dedf9254 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -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, }; diff --git a/packages/server/src/services/MustacheService.ts b/packages/server/src/services/MustacheService.ts index 47ec3b53c..6e23806d1 100644 --- a/packages/server/src/services/MustacheService.ts +++ b/packages/server/src/services/MustacheService.ts @@ -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; // 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'), }, }; diff --git a/packages/server/src/utils/defaultView.ts b/packages/server/src/utils/defaultView.ts index cb6ae8d87..a1f0ee9a5 100644 --- a/packages/server/src/utils/defaultView.ts +++ b/packages/server/src/utils/defaultView.ts @@ -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, diff --git a/packages/server/src/utils/routeUtils.test.ts b/packages/server/src/utils/routeUtils.test.ts index 1b13f0df6..be4ab2c18 100644 --- a/packages/server/src/utils/routeUtils.test.ts +++ b/packages/server/src/utils/routeUtils.test.ts @@ -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() { diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index cf8f90083..a700760b1 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -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 diff --git a/packages/server/src/utils/testing/testUtils.ts b/packages/server/src/utils/testing/testUtils.ts index 0fd536ea8..0d515ba4a 100644 --- a/packages/server/src/utils/testing/testUtils.ts +++ b/packages/server/src/utils/testing/testUtils.ts @@ -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) { diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts index 89537c869..f4fdd8a35 100644 --- a/packages/server/src/utils/types.ts +++ b/packages/server/src/utils/types.ts @@ -144,6 +144,7 @@ export interface Config { tempDir: string; baseUrl: string; apiBaseUrl: string; + adminBaseUrl: string; userContentBaseUrl: string; joplinAppBaseUrl: string; signupEnabled: boolean; diff --git a/packages/server/src/utils/urlUtils.ts b/packages/server/src/utils/urlUtils.ts index 68b6d6565..3c9e8f334 100644 --- a/packages/server/src/utils/urlUtils.ts +++ b/packages/server/src/utils/urlUtils.ts @@ -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`; +} diff --git a/packages/server/src/views/admin/dashboard.mustache b/packages/server/src/views/admin/dashboard.mustache new file mode 100644 index 000000000..3d02ac978 --- /dev/null +++ b/packages/server/src/views/admin/dashboard.mustache @@ -0,0 +1,4 @@ +

{{global.appName}} admin dashboard

+
+

This is the admin dashboard. Please select an option on the left.

+
\ No newline at end of file diff --git a/packages/server/src/views/index/tasks.mustache b/packages/server/src/views/admin/tasks.mustache similarity index 100% rename from packages/server/src/views/index/tasks.mustache rename to packages/server/src/views/admin/tasks.mustache diff --git a/packages/server/src/views/admin/user.mustache b/packages/server/src/views/admin/user.mustache new file mode 100644 index 000000000..c587186e8 --- /dev/null +++ b/packages/server/src/views/admin/user.mustache @@ -0,0 +1,170 @@ +

{{pageTitle}}

+ +
+ +
+ {{> errorBanner}} + {{{csrfTag}}} + + +
+ +
+ +
+
+
+ +
+ +
+
+ + {{#showAccountTypes}} +
+ +
+ +
+

If the below properties are left to their default (empty) values, the account-specific properties will apply.

+
+ {{/showAccountTypes}} + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+

+
+ +
+ +
+ +
+

When creating a new user, if no password is specified the user will have to set it by following the link in their email.

+
+ +
+ + {{#showImpersonateButton}} + + {{/showImpersonateButton}} + {{#showResetPasswordButton}} + + {{/showResetPasswordButton}} + {{#showDisableButton}} + + {{/showDisableButton}} + {{#showRestoreButton}} + + {{/showRestoreButton}} + {{#showScheduleDeletionButton}} + + {{/showScheduleDeletionButton}} +
+
+ + {{#subscription}} +

Your subscription

+ +
+
+

Stripe Subscription ID: {{subscription.stripe_subscription_id}}

+

Last payment status: {{subLastPaymentStatus}} on {{subLastPaymentDate}}

+ {{#showUpdateSubscriptionBasic}} + + {{/showUpdateSubscriptionBasic}} + {{#showUpdateSubscriptionPro}} + + {{/showUpdateSubscriptionPro}} +
+
+ {{/subscription}} + +
+ +{{#hasFlags}} +
+

Flags

+
+ {{{csrfTag}}} + {{#userFlagViews}} +
    +
  • +
+ {{/userFlagViews}} + +

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.

+
+
+{{/hasFlags}} + + diff --git a/packages/server/src/views/index/user_deletions.mustache b/packages/server/src/views/admin/user_deletions.mustache similarity index 100% rename from packages/server/src/views/index/user_deletions.mustache rename to packages/server/src/views/admin/user_deletions.mustache diff --git a/packages/server/src/views/admin/users.mustache b/packages/server/src/views/admin/users.mustache new file mode 100644 index 000000000..db6af3d60 --- /dev/null +++ b/packages/server/src/views/admin/users.mustache @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + {{#users}} + + + + + + + + + + {{/users}} + +
Full nameEmailAccountMax Item SizeTotal SizeMax Total SizeCan share
{{displayName}}{{email}}{{formattedAccountType}}{{formattedItemMaxSize}}{{formattedTotalSize}} ({{formattedTotalSizePercent}}){{formattedMaxTotalSize}}{{formattedCanShareFolder}}
+ + + + \ No newline at end of file diff --git a/packages/server/src/views/index/user.mustache b/packages/server/src/views/index/user.mustache index d5353491c..5dc9c513d 100644 --- a/packages/server/src/views/index/user.mustache +++ b/packages/server/src/views/index/user.mustache @@ -20,58 +20,6 @@ - {{#global.owner.is_admin}} - {{#showAccountTypes}} -
- -
- -
-

If the below properties are left to their default (empty) values, the account-specific properties will apply.

-
- {{/showAccountTypes}} - -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- {{/global.owner.is_admin}} -
@@ -85,28 +33,10 @@
- {{#global.owner.is_admin}} -

When creating a new user, if no password is specified the user will have to set it by following the link in their email.

- {{/global.owner.is_admin}} -
+
- {{#showImpersonateButton}} - - {{/showImpersonateButton}} - {{#showResetPasswordButton}} - - {{/showResetPasswordButton}} - {{#showDisableButton}} - - {{/showDisableButton}} - {{#showRestoreButton}} - - {{/showRestoreButton}} - {{#showScheduleDeletionButton}} - - {{/showScheduleDeletionButton}}
@@ -114,33 +44,17 @@

Your subscription

- {{#global.owner.is_admin}} + {{#showUpdateSubscriptionPro}}
-

Stripe Subscription ID: {{subscription.stripe_subscription_id}}

-

Last payment status: {{subLastPaymentStatus}} on {{subLastPaymentDate}}

- {{#showUpdateSubscriptionBasic}} - - {{/showUpdateSubscriptionBasic}} - {{#showUpdateSubscriptionPro}} - - {{/showUpdateSubscriptionPro}} +

Upgrade to Pro

+

Click for more info about the Pro plan and to upgrade your account.

- {{/global.owner.is_admin}} - - {{^global.owner.is_admin}} - {{#showUpdateSubscriptionPro}} -
-

Upgrade to Pro

-

Click for more info about the Pro plan and to upgrade your account.

-
- {{/showUpdateSubscriptionPro}} - {{#showManageSubscription}} -
-

Manage subscription

-

Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.

-
- {{/showManageSubscription}} - {{/global.owner.is_admin}} + {{/showUpdateSubscriptionPro}} + +
+

Manage subscription

+

Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.

+
{{/subscription}} @@ -164,28 +78,6 @@ diff --git a/packages/server/src/views/layouts/default.mustache b/packages/server/src/views/layouts/default.mustache index f67eb9347..b439adc93 100644 --- a/packages/server/src/views/layouts/default.mustache +++ b/packages/server/src/views/layouts/default.mustache @@ -24,7 +24,14 @@
{{> notifications}} - {{{contentHtml}}} + + {{#global.isAdminPage}} + {{> adminLayout}} + {{/global.isAdminPage}} + + {{^global.isAdminPage}} + {{{contentHtml}}} + {{/global.isAdminPage}}
{{> footer}} diff --git a/packages/server/src/views/partials/adminLayout.mustache b/packages/server/src/views/partials/adminLayout.mustache new file mode 100644 index 000000000..e0dc353a6 --- /dev/null +++ b/packages/server/src/views/partials/adminLayout.mustache @@ -0,0 +1,20 @@ +
+
+ +
+ +
+
+ {{{contentHtml}}} +
+
+
\ No newline at end of file diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache index bb63fd629..79437a180 100644 --- a/packages/server/src/views/partials/navbar.mustache +++ b/packages/server/src/views/partials/navbar.mustache @@ -10,19 +10,9 @@ {{#global.owner}}