mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-11 18:24:43 +02:00
Server: Move admin pages under /admin (#6006)
This commit is contained in:
parent
3fcdeb08d9
commit
ca7e68ba4b
@ -16,4 +16,6 @@ module.exports = {
|
|||||||
'jest-expect-message',
|
'jest-expect-message',
|
||||||
`${__dirname}/jest.setup.js`,
|
`${__dirname}/jest.setup.js`,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
bail: true,
|
||||||
};
|
};
|
||||||
|
@ -61,7 +61,8 @@ ul li {
|
|||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.pagination-list li {
|
ul.pagination-list li,
|
||||||
|
ul.menu-list li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,7 +161,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
} catch (anotherError) {
|
} catch (anotherError) {
|
||||||
ctx.response.set('Content-Type', 'application/json');
|
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 {
|
} else {
|
||||||
ctx.response.set('Content-Type', 'application/json');
|
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),
|
stripe: stripeConfigFromEnv(stripePublicConfig, env),
|
||||||
port: appPort,
|
port: appPort,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
adminBaseUrl: `${baseUrl}/admin`,
|
||||||
showErrorStackTraces: env.ERROR_STACK_TRACES,
|
showErrorStackTraces: env.ERROR_STACK_TRACES,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
|
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 config from '../config';
|
||||||
import { userIp } from '../utils/requestUtils';
|
import { userIp } from '../utils/requestUtils';
|
||||||
import { createCsrfTag } from '../utils/csrf';
|
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) {
|
export default async function(ctx: AppContext) {
|
||||||
const requestStartTime = Date.now();
|
const requestStartTime = Date.now();
|
||||||
@ -20,6 +20,7 @@ export default async function(ctx: AppContext) {
|
|||||||
const view = responseObject as View;
|
const view = responseObject as View;
|
||||||
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
||||||
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
||||||
|
currentUrl: ctx.URL,
|
||||||
notifications: ctx.joplin.notifications || [],
|
notifications: ctx.joplin.notifications || [],
|
||||||
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
||||||
owner: ctx.joplin.owner,
|
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);
|
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;
|
const user = ctx.joplin.owner;
|
||||||
if (!user.is_admin) throw new ErrorForbidden();
|
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));
|
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;
|
const user = ctx.joplin.owner;
|
||||||
if (!user.is_admin) throw new ErrorForbidden();
|
if (!user.is_admin) throw new ErrorForbidden();
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultView('tasks', 'Tasks'),
|
...defaultView('admin/tasks', 'Tasks'),
|
||||||
content: {
|
content: {
|
||||||
itemTable: makeTableView(table),
|
itemTable: makeTableView(table),
|
||||||
postUrl: makeUrl(UrlType.Tasks),
|
postUrl: makeUrl(UrlType.Tasks),
|
@ -8,13 +8,13 @@ import { yesOrNo } from '../../utils/strings';
|
|||||||
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
||||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||||
import { formatDateTime } from '../../utils/time';
|
import { formatDateTime } from '../../utils/time';
|
||||||
import { userDeletionsUrl, userUrl } from '../../utils/urlUtils';
|
import { adminUserDeletionsUrl, userUrl } from '../../utils/urlUtils';
|
||||||
import { createCsrfTag } from '../../utils/csrf';
|
import { createCsrfTag } from '../../utils/csrf';
|
||||||
import { bodyFields } from '../../utils/requestUtils';
|
import { bodyFields } from '../../utils/requestUtils';
|
||||||
|
|
||||||
const router: Router = new Router(RouteType.Web);
|
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;
|
const user = ctx.joplin.owner;
|
||||||
if (!user.is_admin) throw new ErrorForbidden();
|
if (!user.is_admin) throw new ErrorForbidden();
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
console.info(page);
|
console.info(page);
|
||||||
|
|
||||||
const table: Table = {
|
const table: Table = {
|
||||||
baseUrl: userDeletionsUrl(),
|
baseUrl: adminUserDeletionsUrl(),
|
||||||
requestQuery: ctx.query,
|
requestQuery: ctx.query,
|
||||||
pageCount: page.page_count,
|
pageCount: page.page_count,
|
||||||
pagination,
|
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 = {
|
view.content = {
|
||||||
userDeletionTable: makeTableView(table),
|
userDeletionTable: makeTableView(table),
|
||||||
postUrl: makeUrl(UrlType.UserDeletions),
|
postUrl: makeUrl(UrlType.UserDeletions),
|
||||||
@ -124,7 +124,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
throw new ErrorMethodNotAllowed();
|
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;
|
const user = ctx.joplin.owner;
|
||||||
if (!user.is_admin) throw new ErrorForbidden();
|
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 routeHandler from '../../middleware/routeHandler';
|
||||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
|
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
|
||||||
|
|
||||||
describe('index_home', function() {
|
describe('index/home', function() {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await beforeAllDb('index_home');
|
await beforeAllDb('index/home');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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 { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError, expectThrow } from '../../utils/testing/testUtils';
|
||||||
import uuidgen from '../../utils/uuidgen';
|
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;
|
password = password === null ? uuidgen() : password;
|
||||||
|
|
||||||
const context = await koaAppContext({
|
const context = await koaAppContext({
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
request: {
|
request: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/users/new',
|
url: '/admin/users/new',
|
||||||
body: {
|
body: {
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
@ -30,7 +30,7 @@ export async function postUser(sessionId: string, email: string, password: strin
|
|||||||
return context.response.body;
|
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({
|
const context = await koaAppContext({
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
request: {
|
request: {
|
||||||
@ -48,7 +48,7 @@ export async function patchUser(sessionId: string, user: any, url: string = ''):
|
|||||||
return context.response.body;
|
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({
|
const context = await koaAppContext({
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
request: {
|
request: {
|
||||||
@ -76,53 +76,6 @@ describe('index/users', function() {
|
|||||||
await beforeEachDb();
|
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() {
|
test('new user should be able to login', async function() {
|
||||||
const { session } = await createUserAndSession(1, true);
|
const { session } = await createUserAndSession(1, true);
|
||||||
|
|
||||||
@ -133,39 +86,6 @@ describe('index/users', function() {
|
|||||||
expect(loggedInUser.email).toBe('test@example.com');
|
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() {
|
test('should change user properties', async function() {
|
||||||
const { user, session } = await createUserAndSession(1, false);
|
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');
|
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() {
|
test('should allow user to set a password for new accounts', async function() {
|
||||||
let user1 = await models().user().save({
|
let user1 = await models().user().save({
|
||||||
email: 'user1@localhost',
|
email: 'user1@localhost',
|
||||||
@ -366,33 +277,31 @@ describe('index/users', function() {
|
|||||||
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
|
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete sessions when changing password', async function() {
|
test('should not change non-whitelisted properties', async () => {
|
||||||
const { user, session, password } = await createUserAndSession(1);
|
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
||||||
|
|
||||||
await models().session().authenticate(user.email, password);
|
await patchUser(session1.id, {
|
||||||
await models().session().authenticate(user.email, password);
|
id: user1.id,
|
||||||
await models().session().authenticate(user.email, password);
|
is_admin: 1,
|
||||||
|
max_item_size: 555,
|
||||||
expect(await models().session().count()).toBe(4);
|
max_total_item_size: 5555,
|
||||||
|
can_share_folder: 1,
|
||||||
await patchUser(session.id, {
|
can_upload: 0,
|
||||||
id: user.id,
|
});
|
||||||
email: 'changed@example.com',
|
const reloadedUser1 = await models().user().load(user1.id);
|
||||||
password: 'hunter11hunter22hunter33',
|
expect(reloadedUser1.is_admin).toBe(0);
|
||||||
password2: 'hunter11hunter22hunter33',
|
expect(reloadedUser1.max_item_size).toBe(null);
|
||||||
}, '/users/me');
|
expect(reloadedUser1.max_total_item_size).toBe(null);
|
||||||
|
expect(reloadedUser1.can_share_folder).toBe(null);
|
||||||
const sessions = await models().session().all();
|
expect(reloadedUser1.can_upload).toBe(1);
|
||||||
expect(sessions.length).toBe(1);
|
|
||||||
expect(sessions[0].id).toBe(session.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should apply ACL', async function() {
|
test('should apply ACL', async function() {
|
||||||
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
|
const { user: admin } = await createUserAndSession(1, true);
|
||||||
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
const { session: session1 } = await createUserAndSession(2, false);
|
||||||
|
|
||||||
// non-admin cannot list users
|
// 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
|
// non-admin user cannot view another user
|
||||||
await expectHttpError(async () => execRequest(session1.id, 'GET', `users/${admin.id}`), ErrorForbidden.httpCode);
|
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
|
// non-admin user cannot update another user
|
||||||
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, email: 'cantdothateither@example.com' }), ErrorForbidden.httpCode);
|
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 { AppContext, HttpMethod } from '../../utils/types';
|
||||||
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
|
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
|
||||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
|
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 config from '../../config';
|
||||||
import { View } from '../../services/MustacheService';
|
import { View } from '../../services/MustacheService';
|
||||||
import defaultView from '../../utils/defaultView';
|
import defaultView from '../../utils/defaultView';
|
||||||
import { AclAction } from '../../models/BaseModel';
|
import { AclAction } from '../../models/BaseModel';
|
||||||
import { NotificationKey } from '../../models/NotificationModel';
|
import { NotificationKey } from '../../models/NotificationModel';
|
||||||
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
|
import { AccountType, accountTypeOptions } from '../../models/UserModel';
|
||||||
import uuidgen from '../../utils/uuidgen';
|
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
|
||||||
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
|
import { updateCustomerEmail } from '../../utils/stripe';
|
||||||
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 { createCsrfTag } from '../../utils/csrf';
|
import { createCsrfTag } from '../../utils/csrf';
|
||||||
import { formatDateTime, Hour } from '../../utils/time';
|
import { formatDateTime } from '../../utils/time';
|
||||||
import { cookieSet } from '../../utils/cookies';
|
import { cookieSet } from '../../utils/cookies';
|
||||||
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
|
|
||||||
import { userFlagToString } from '../../models/UserFlagModel';
|
import { userFlagToString } from '../../models/UserFlagModel';
|
||||||
|
import { stopImpersonating } from '../admin/utils/users/impersonate';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
|
||||||
export interface CheckRepeatPasswordInput {
|
export interface CheckRepeatPasswordInput {
|
||||||
password: string;
|
password: string;
|
||||||
@ -39,120 +36,40 @@ export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required:
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
|
function makeUser(userId: Uuid, fields: any): User {
|
||||||
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 = {};
|
const user: User = {};
|
||||||
|
|
||||||
if ('email' in fields) user.email = fields.email;
|
if ('email' in fields) user.email = fields.email;
|
||||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
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);
|
const password = checkRepeatPassword(fields, false);
|
||||||
if (password) user.password = password;
|
if (password) user.password = password;
|
||||||
|
|
||||||
if (!isNew) user.id = fields.id;
|
user.id = userId;
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
user.must_set_password = user.password ? 0 : 1;
|
|
||||||
user.password = user.password ? user.password : uuidgen();
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
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);
|
const router = new Router(RouteType.Web);
|
||||||
|
|
||||||
router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User = null, error: any = null) => {
|
||||||
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) => {
|
|
||||||
const owner = ctx.joplin.owner;
|
const owner = ctx.joplin.owner;
|
||||||
const isMe = userIsMe(path);
|
if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
|
||||||
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) : null;
|
const models = ctx.joplin.models;
|
||||||
if (isNew && !user) user = defaultUser();
|
const userId = owner.id;
|
||||||
|
|
||||||
|
const user = await models.user().load(userId);
|
||||||
|
|
||||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
|
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
|
||||||
|
|
||||||
let postUrl = '';
|
const postUrl = `${config().baseUrl}/users/me`;
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
postUrl = `${config().baseUrl}/users/new`;
|
|
||||||
} else if (isMe) {
|
|
||||||
postUrl = `${config().baseUrl}/users/me`;
|
|
||||||
} else {
|
|
||||||
postUrl = `${config().baseUrl}/users/${user.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserFlagView extends UserFlag {
|
interface UserFlagView extends UserFlag {
|
||||||
message: string;
|
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 {
|
return {
|
||||||
...f,
|
...f,
|
||||||
message: userFlagToString(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 = [];
|
if (!owner.is_admin) userFlagViews = [];
|
||||||
|
|
||||||
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
|
const subscription = await ctx.joplin.models.subscription().byUserId(userId);
|
||||||
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
|
|
||||||
|
|
||||||
const view: View = defaultView('user', 'Profile');
|
const view: View = defaultView('user', 'Profile');
|
||||||
view.content.user = user;
|
view.content.user = formUser ? formUser : user;
|
||||||
view.content.isNew = isNew;
|
view.content.buttonTitle = _('Update profile');
|
||||||
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
|
|
||||||
view.content.error = error;
|
view.content.error = error;
|
||||||
view.content.postUrl = postUrl;
|
view.content.postUrl = postUrl;
|
||||||
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
|
|
||||||
view.content.csrfTag = await createCsrfTag(ctx);
|
view.content.csrfTag = await createCsrfTag(ctx);
|
||||||
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
||||||
|
|
||||||
view.content.subscription = subscription;
|
view.content.subscription = subscription;
|
||||||
view.content.showManageSubscription = !isNew;
|
view.content.showUpdateSubscriptionBasic = user.account_type !== AccountType.Basic;
|
||||||
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
|
view.content.showUpdateSubscriptionPro = user.account_type !== AccountType.Pro;
|
||||||
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
|
|
||||||
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
||||||
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
|
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.hasFlags = !!userFlagViews.length;
|
||||||
view.content.userFlagViews = userFlagViews;
|
view.content.userFlagViews = userFlagViews;
|
||||||
view.content.stripePortalUrl = stripePortalUrl();
|
view.content.stripePortalUrl = stripePortalUrl();
|
||||||
@ -303,98 +210,50 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
|
|||||||
interface FormFields {
|
interface FormFields {
|
||||||
id: Uuid;
|
id: Uuid;
|
||||||
post_button: string;
|
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_basic_button: string;
|
||||||
update_subscription_pro_button: string;
|
update_subscription_pro_button: string;
|
||||||
// user_cancel_subscription_button: string;
|
|
||||||
impersonate_button: string;
|
|
||||||
stop_impersonate_button: string;
|
stop_impersonate_button: string;
|
||||||
delete_user_flags: string;
|
|
||||||
schedule_deletion_button: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('users', async (path: SubPath, ctx: AppContext) => {
|
router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||||
let user: User = {};
|
|
||||||
const owner = ctx.joplin.owner;
|
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 {
|
try {
|
||||||
const body = await formParse(ctx.req);
|
const body = await formParse(ctx.req);
|
||||||
const fields = body.fields as FormFields;
|
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) {
|
if (fields.post_button) {
|
||||||
const userToSave: User = models.user().fromApiInput(user);
|
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) {
|
if (userToSave.email && userToSave.email !== owner.email) {
|
||||||
await models.user().save(userToSave);
|
await models.user().initiateEmailChange(owner.id, userToSave.email);
|
||||||
} else {
|
delete userToSave.email;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} else if (fields.stop_impersonate_button) {
|
||||||
await stopImpersonating(ctx);
|
await stopImpersonating(ctx);
|
||||||
return redirect(ctx, config().baseUrl);
|
return redirect(ctx, config().baseUrl);
|
||||||
} else if (owner.is_admin) {
|
} else {
|
||||||
if (fields.disable_button || fields.restore_button) {
|
throw new Error('Invalid form 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : `/${userId}`}`);
|
return redirect(ctx, `${config().baseUrl}/users/me`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.message = `Error: Your changes were not saved: ${error.message}`;
|
error.message = `Error: Your changes were not saved: ${error.message}`;
|
||||||
if (error instanceof ErrorForbidden) throw error;
|
if (error instanceof ErrorForbidden) throw error;
|
||||||
|
@ -5,12 +5,17 @@ import apiBatchItems from './api/batch_items';
|
|||||||
import apiDebug from './api/debug';
|
import apiDebug from './api/debug';
|
||||||
import apiEvents from './api/events';
|
import apiEvents from './api/events';
|
||||||
import apiItems from './api/items';
|
import apiItems from './api/items';
|
||||||
|
import apiLocks from './api/locks';
|
||||||
import apiPing from './api/ping';
|
import apiPing from './api/ping';
|
||||||
import apiSessions from './api/sessions';
|
import apiSessions from './api/sessions';
|
||||||
import apiShares from './api/shares';
|
import apiShares from './api/shares';
|
||||||
import apiShareUsers from './api/share_users';
|
import apiShareUsers from './api/share_users';
|
||||||
import apiUsers from './api/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 indexChanges from './index/changes';
|
||||||
import indexHelp from './index/help';
|
import indexHelp from './index/help';
|
||||||
@ -24,44 +29,45 @@ import indexPrivacy from './index/privacy';
|
|||||||
import indexShares from './index/shares';
|
import indexShares from './index/shares';
|
||||||
import indexSignup from './index/signup';
|
import indexSignup from './index/signup';
|
||||||
import indexStripe from './index/stripe';
|
import indexStripe from './index/stripe';
|
||||||
import indexTasks from './index/tasks';
|
|
||||||
import indexTerms from './index/terms';
|
import indexTerms from './index/terms';
|
||||||
import indexUpgrade from './index/upgrade';
|
import indexUpgrade from './index/upgrade';
|
||||||
import indexUsers from './index/users';
|
import indexUsers from './index/users';
|
||||||
import indexUserDeletions from './index/user_deletions';
|
|
||||||
|
|
||||||
import defaultRoute from './default';
|
import defaultRoute from './default';
|
||||||
|
|
||||||
const routes: Routers = {
|
const routes: Routers = {
|
||||||
'api/batch': apiBatch,
|
|
||||||
'api/batch_items': apiBatchItems,
|
'api/batch_items': apiBatchItems,
|
||||||
|
'api/batch': apiBatch,
|
||||||
'api/debug': apiDebug,
|
'api/debug': apiDebug,
|
||||||
'api/events': apiEvents,
|
'api/events': apiEvents,
|
||||||
'api/items': apiItems,
|
'api/items': apiItems,
|
||||||
|
'api/locks': apiLocks,
|
||||||
'api/ping': apiPing,
|
'api/ping': apiPing,
|
||||||
'api/sessions': apiSessions,
|
'api/sessions': apiSessions,
|
||||||
'api/share_users': apiShareUsers,
|
'api/share_users': apiShareUsers,
|
||||||
'api/shares': apiShares,
|
'api/shares': apiShares,
|
||||||
'api/users': apiUsers,
|
'api/users': apiUsers,
|
||||||
'api/locks': apiLocks,
|
|
||||||
|
'admin/dashboard': adminDashboard,
|
||||||
|
'admin/tasks': adminTasks,
|
||||||
|
'admin/user_deletions': adminUserDeletions,
|
||||||
|
'admin/users': adminUsers,
|
||||||
|
|
||||||
'changes': indexChanges,
|
'changes': indexChanges,
|
||||||
|
'help': indexHelp,
|
||||||
'home': indexHome,
|
'home': indexHome,
|
||||||
'items': indexItems,
|
'items': indexItems,
|
||||||
'password': indexPassword,
|
|
||||||
'login': indexLogin,
|
'login': indexLogin,
|
||||||
'logout': indexLogout,
|
'logout': indexLogout,
|
||||||
'notifications': indexNotifications,
|
'notifications': indexNotifications,
|
||||||
'signup': indexSignup,
|
'password': indexPassword,
|
||||||
|
'privacy': indexPrivacy,
|
||||||
'shares': indexShares,
|
'shares': indexShares,
|
||||||
'users': indexUsers,
|
'signup': indexSignup,
|
||||||
'stripe': indexStripe,
|
'stripe': indexStripe,
|
||||||
'terms': indexTerms,
|
'terms': indexTerms,
|
||||||
'privacy': indexPrivacy,
|
|
||||||
'upgrade': indexUpgrade,
|
'upgrade': indexUpgrade,
|
||||||
'help': indexHelp,
|
'users': indexUsers,
|
||||||
'tasks': indexTasks,
|
|
||||||
'user_deletions': indexUserDeletions,
|
|
||||||
|
|
||||||
'': defaultRoute,
|
'': defaultRoute,
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,19 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
|
|||||||
import MarkdownIt = require('markdown-it');
|
import MarkdownIt = require('markdown-it');
|
||||||
import { headerAnchor } from '@joplin/renderer';
|
import { headerAnchor } from '@joplin/renderer';
|
||||||
import { _ } from '@joplin/lib/locale';
|
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 {
|
export interface RenderOptions {
|
||||||
partials?: any;
|
partials?: any;
|
||||||
@ -48,6 +61,10 @@ interface GlobalParams {
|
|||||||
impersonatorAdminSessionId?: string;
|
impersonatorAdminSessionId?: string;
|
||||||
csrfTag?: string;
|
csrfTag?: string;
|
||||||
s?: Record<string, string>; // List of translatable strings
|
s?: Record<string, string>; // List of translatable strings
|
||||||
|
isAdminPage?: boolean;
|
||||||
|
adminMenu?: MenuItem[];
|
||||||
|
navbarMenu?: MenuItem[];
|
||||||
|
currentUrl?: URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isView(o: any): boolean {
|
export function isView(o: any): boolean {
|
||||||
@ -95,6 +112,88 @@ export default class MustacheService {
|
|||||||
return `${config().layoutDir}/${name}.mustache`;
|
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 {
|
private get defaultLayoutOptions(): GlobalParams {
|
||||||
return {
|
return {
|
||||||
baseUrl: config().baseUrl,
|
baseUrl: config().baseUrl,
|
||||||
@ -187,7 +286,10 @@ export default class MustacheService {
|
|||||||
globalParams = {
|
globalParams = {
|
||||||
...this.defaultLayoutOptions,
|
...this.defaultLayoutOptions,
|
||||||
...globalParams,
|
...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),
|
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
|
||||||
|
isAdminPage: view.path.startsWith('/admin/'),
|
||||||
s: {
|
s: {
|
||||||
home: _('Home'),
|
home: _('Home'),
|
||||||
users: _('Users'),
|
users: _('Users'),
|
||||||
@ -196,6 +298,7 @@ export default class MustacheService {
|
|||||||
tasks: _('Tasks'),
|
tasks: _('Tasks'),
|
||||||
help: _('Help'),
|
help: _('Help'),
|
||||||
logout: _('Logout'),
|
logout: _('Logout'),
|
||||||
|
admin: _('Admin'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,9 +2,11 @@ import { View } from '../services/MustacheService';
|
|||||||
|
|
||||||
// Populate a View object with some good defaults.
|
// Populate a View object with some good defaults.
|
||||||
export default function(name: string, title: string): View {
|
export default function(name: string, title: string): View {
|
||||||
|
const pathPrefix = name.startsWith('admin/') ? '' : 'index/';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: name,
|
name: name,
|
||||||
path: `index/${name}`,
|
path: `${pathPrefix}/${name}`,
|
||||||
content: {},
|
content: {},
|
||||||
navbar: true,
|
navbar: true,
|
||||||
title: title,
|
title: title,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
||||||
import { ItemAddressingType } from '../services/database/types';
|
import { ItemAddressingType } from '../services/database/types';
|
||||||
import { RouteType } from './types';
|
import { RouteType } from './types';
|
||||||
|
import { expectThrow } from './testing/testUtils';
|
||||||
|
|
||||||
describe('routeUtils', function() {
|
describe('routeUtils', function() {
|
||||||
|
|
||||||
@ -76,6 +77,9 @@ describe('routeUtils', function() {
|
|||||||
const actual = findMatchingRoute(path, routes);
|
const actual = findMatchingRoute(path, routes);
|
||||||
expect(actual).toEqual(expected);
|
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() {
|
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 ID: "SOME_ID"
|
||||||
// - The link: "content"
|
// - The link: "content"
|
||||||
export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
|
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('/');
|
const splittedPath = path.split('/');
|
||||||
|
|
||||||
// Because the path starts with "/", we remove the first element, which is
|
// 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 { createCsrfToken } from '../csrf';
|
||||||
import { cookieSet } from '../cookies';
|
import { cookieSet } from '../cookies';
|
||||||
import { parseEnv } from '../../env';
|
import { parseEnv } from '../../env';
|
||||||
|
import { URL } from 'url';
|
||||||
|
|
||||||
// Takes into account the fact that this file will be inside the /dist directory
|
// Takes into account the fact that this file will be inside the /dist directory
|
||||||
// when it runs.
|
// when it runs.
|
||||||
@ -218,7 +219,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
|
|||||||
query: req.query,
|
query: req.query,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
redirect: () => {},
|
redirect: () => {},
|
||||||
URL: { origin: config().baseUrl },
|
URL: new URL(config().baseUrl), // origin
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.sessionId) {
|
if (options.sessionId) {
|
||||||
|
@ -144,6 +144,7 @@ export interface Config {
|
|||||||
tempDir: string;
|
tempDir: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
|
adminBaseUrl: string;
|
||||||
userContentBaseUrl: string;
|
userContentBaseUrl: string;
|
||||||
joplinAppBaseUrl: string;
|
joplinAppBaseUrl: string;
|
||||||
signupEnabled: boolean;
|
signupEnabled: boolean;
|
||||||
|
@ -14,6 +14,14 @@ export function setQueryParameters(url: string, query: any): string {
|
|||||||
return u.toString();
|
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 {
|
export function resetPasswordUrl(token: string): string {
|
||||||
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
|
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
|
||||||
}
|
}
|
||||||
@ -42,14 +50,38 @@ export function homeUrl(): string {
|
|||||||
return `${config().baseUrl}/home`;
|
return `${config().baseUrl}/home`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function itemsUrl(): string {
|
||||||
|
return `${config().baseUrl}/items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changesUrl(): string {
|
||||||
|
return `${config().baseUrl}/changes`;
|
||||||
|
}
|
||||||
|
|
||||||
export function loginUrl(): string {
|
export function loginUrl(): string {
|
||||||
return `${config().baseUrl}/login`;
|
return `${config().baseUrl}/login`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userDeletionsUrl(): string {
|
export function adminUserDeletionsUrl(): string {
|
||||||
return `${config().baseUrl}/user_deletions`;
|
return `${config().adminBaseUrl}/user_deletions`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userUrl(userId: Uuid): string {
|
export function userUrl(userId: Uuid): string {
|
||||||
return `${config().baseUrl}/users/${userId}`;
|
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>
|
||||||
</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">
|
<div class="field">
|
||||||
<label class="label">Password</label>
|
<label class="label">Password</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@ -85,28 +33,10 @@
|
|||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="password" name="password2" autocomplete="new-password"/>
|
<input class="input" type="password" name="password2" autocomplete="new-password"/>
|
||||||
</div>
|
</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">
|
<div class="control block">
|
||||||
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -114,33 +44,17 @@
|
|||||||
<h1 class="title">Your subscription</h1>
|
<h1 class="title">Your subscription</h1>
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{{#global.owner.is_admin}}
|
{{#showUpdateSubscriptionPro}}
|
||||||
<div class="control 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><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
|
||||||
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
|
<p class="help">Click for more info about the Pro plan and to upgrade your account.</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>
|
||||||
{{/global.owner.is_admin}}
|
{{/showUpdateSubscriptionPro}}
|
||||||
|
|
||||||
{{^global.owner.is_admin}}
|
<div class="control block">
|
||||||
{{#showUpdateSubscriptionPro}}
|
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
|
||||||
<div class="control block">
|
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
|
||||||
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
|
</div>
|
||||||
<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}}
|
|
||||||
</div>
|
</div>
|
||||||
{{/subscription}}
|
{{/subscription}}
|
||||||
|
|
||||||
@ -164,28 +78,6 @@
|
|||||||
|
|
||||||
<script>
|
<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();
|
setupPasswordStrengthHandler();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -24,7 +24,14 @@
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{{> notifications}}
|
{{> notifications}}
|
||||||
{{{contentHtml}}}
|
|
||||||
|
{{#global.isAdminPage}}
|
||||||
|
{{> adminLayout}}
|
||||||
|
{{/global.isAdminPage}}
|
||||||
|
|
||||||
|
{{^global.isAdminPage}}
|
||||||
|
{{{contentHtml}}}
|
||||||
|
{{/global.isAdminPage}}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{{> footer}}
|
{{> 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}}
|
{{#global.owner}}
|
||||||
<div class="navbar-menu is-active">
|
<div class="navbar-menu is-active">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<a class="navbar-item" href="{{{global.baseUrl}}}/home">{{global.s.home}}</a>
|
{{#global.navbarMenu}}
|
||||||
{{#global.owner.is_admin}}
|
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i> {{/icon}}{{title}}</a>
|
||||||
<a class="navbar-item" href="{{{global.baseUrl}}}/users">{{global.s.users}}</a>
|
{{/global.navbarMenu}}
|
||||||
{{/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}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
{{#global.isJoplinCloud}}
|
{{#global.isJoplinCloud}}
|
||||||
|
Loading…
Reference in New Issue
Block a user