You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Server: Move admin pages under /admin (#6006)
This commit is contained in:
		| @@ -16,4 +16,6 @@ module.exports = { | ||||
| 		'jest-expect-message', | ||||
| 		`${__dirname}/jest.setup.js`, | ||||
| 	], | ||||
|  | ||||
| 	bail: true, | ||||
| }; | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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'); | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										14
									
								
								packages/server/src/routes/admin/dashboard.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/server/src/routes/admin/dashboard.ts
									
									
									
									
									
										Normal 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; | ||||
| @@ -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), | ||||
| @@ -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(); | ||||
| 
 | ||||
							
								
								
									
										183
									
								
								packages/server/src/routes/admin/users.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								packages/server/src/routes/admin/users.test.ts
									
									
									
									
									
										Normal 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); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										311
									
								
								packages/server/src/routes/admin/users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										311
									
								
								packages/server/src/routes/admin/users.ts
									
									
									
									
									
										Normal 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; | ||||
| @@ -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 () => { | ||||
|   | ||||
| @@ -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); | ||||
| 	}); | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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, | ||||
| }; | ||||
|   | ||||
| @@ -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'), | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -144,6 +144,7 @@ export interface Config { | ||||
| 	tempDir: string; | ||||
| 	baseUrl: string; | ||||
| 	apiBaseUrl: string; | ||||
| 	adminBaseUrl: string; | ||||
| 	userContentBaseUrl: string; | ||||
| 	joplinAppBaseUrl: string; | ||||
| 	signupEnabled: boolean; | ||||
|   | ||||
| @@ -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`; | ||||
| } | ||||
|   | ||||
							
								
								
									
										4
									
								
								packages/server/src/views/admin/dashboard.mustache
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								packages/server/src/views/admin/dashboard.mustache
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										170
									
								
								packages/server/src/views/admin/user.mustache
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								packages/server/src/views/admin/user.mustache
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										60
									
								
								packages/server/src/views/admin/users.mustache
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								packages/server/src/views/admin/users.mustache
									
									
									
									
									
										Normal 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> | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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}} | ||||
|   | ||||
							
								
								
									
										20
									
								
								packages/server/src/views/partials/adminLayout.mustache
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/server/src/views/partials/adminLayout.mustache
									
									
									
									
									
										Normal 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> | ||||
| @@ -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>  {{/icon}}{{title}}</a> | ||||
| 						{{/global.navbarMenu}} | ||||
| 					</div> | ||||
| 					<div class="navbar-end"> | ||||
| 						{{#global.isJoplinCloud}} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user