mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Server: Move admin pages under /admin (#6006)
This commit is contained in:
parent
3fcdeb08d9
commit
ca7e68ba4b
@ -16,4 +16,6 @@ module.exports = {
|
||||
'jest-expect-message',
|
||||
`${__dirname}/jest.setup.js`,
|
||||
],
|
||||
|
||||
bail: true,
|
||||
};
|
||||
|
@ -61,7 +61,8 @@ ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ul.pagination-list li {
|
||||
ul.pagination-list li,
|
||||
ul.menu-list li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
|
@ -161,7 +161,7 @@ async function main() {
|
||||
});
|
||||
} catch (anotherError) {
|
||||
ctx.response.set('Content-Type', 'application/json');
|
||||
ctx.body = JSON.stringify({ error: error.message });
|
||||
ctx.body = JSON.stringify({ error: `${error.message} (Check the server log for more information)` });
|
||||
}
|
||||
} else {
|
||||
ctx.response.set('Content-Type', 'application/json');
|
||||
|
@ -120,6 +120,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
||||
stripe: stripeConfigFromEnv(stripePublicConfig, env),
|
||||
port: appPort,
|
||||
baseUrl,
|
||||
adminBaseUrl: `${baseUrl}/admin`,
|
||||
showErrorStackTraces: env.ERROR_STACK_TRACES,
|
||||
apiBaseUrl,
|
||||
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
|
||||
|
@ -4,7 +4,7 @@ import { isView, View } from '../services/MustacheService';
|
||||
import config from '../config';
|
||||
import { userIp } from '../utils/requestUtils';
|
||||
import { createCsrfTag } from '../utils/csrf';
|
||||
import { getImpersonatorAdminSessionId } from '../routes/index/utils/users/impersonate';
|
||||
import { getImpersonatorAdminSessionId } from '../routes/admin/utils/users/impersonate';
|
||||
|
||||
export default async function(ctx: AppContext) {
|
||||
const requestStartTime = Date.now();
|
||||
@ -20,6 +20,7 @@ export default async function(ctx: AppContext) {
|
||||
const view = responseObject as View;
|
||||
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
||||
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
||||
currentUrl: ctx.URL,
|
||||
notifications: ctx.joplin.notifications || [],
|
||||
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
||||
owner: ctx.joplin.owner,
|
||||
|
14
packages/server/src/routes/admin/dashboard.ts
Normal file
14
packages/server/src/routes/admin/dashboard.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import Router from '../../utils/Router';
|
||||
import { SubPath } from '../../utils/routeUtils';
|
||||
import { AppContext, RouteType } from '../../utils/types';
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('admin/dashboard', async (_path: SubPath, _ctx: AppContext) => {
|
||||
const view = defaultView('admin/dashboard', _('Admin dashboard'));
|
||||
return view;
|
||||
});
|
||||
|
||||
export default router;
|
@ -16,7 +16,7 @@ const prettyCron = require('prettycron');
|
||||
|
||||
const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.post('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
@ -52,7 +52,7 @@ router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
return redirect(ctx, makeUrl(UrlType.Tasks));
|
||||
});
|
||||
|
||||
router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
@ -126,7 +126,7 @@ router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultView('tasks', 'Tasks'),
|
||||
...defaultView('admin/tasks', 'Tasks'),
|
||||
content: {
|
||||
itemTable: makeTableView(table),
|
||||
postUrl: makeUrl(UrlType.Tasks),
|
@ -8,13 +8,13 @@ import { yesOrNo } from '../../utils/strings';
|
||||
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { userDeletionsUrl, userUrl } from '../../utils/urlUtils';
|
||||
import { adminUserDeletionsUrl, userUrl } from '../../utils/urlUtils';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { bodyFields } from '../../utils/requestUtils';
|
||||
|
||||
const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
@ -26,7 +26,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
console.info(page);
|
||||
|
||||
const table: Table = {
|
||||
baseUrl: userDeletionsUrl(),
|
||||
baseUrl: adminUserDeletionsUrl(),
|
||||
requestQuery: ctx.query,
|
||||
pageCount: page.page_count,
|
||||
pagination,
|
||||
@ -110,7 +110,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
}),
|
||||
};
|
||||
|
||||
const view = defaultView('user_deletions', 'User deletions');
|
||||
const view = defaultView('admin/user_deletions', 'User deletions');
|
||||
view.content = {
|
||||
userDeletionTable: makeTableView(table),
|
||||
postUrl: makeUrl(UrlType.UserDeletions),
|
||||
@ -124,7 +124,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
throw new ErrorMethodNotAllowed();
|
||||
});
|
||||
|
||||
router.post('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.post('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
183
packages/server/src/routes/admin/users.test.ts
Normal file
183
packages/server/src/routes/admin/users.test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import { User } from '../../services/database/types';
|
||||
import routeHandler from '../../middleware/routeHandler';
|
||||
import { execRequest } from '../../utils/testing/apiUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
|
||||
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
password = password === null ? uuidgen() : password;
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/admin/users/new',
|
||||
body: {
|
||||
email: email,
|
||||
password: password,
|
||||
password2: password,
|
||||
post_button: true,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await routeHandler(context);
|
||||
checkContextError(context);
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: url ? url : '/admin/users',
|
||||
body: {
|
||||
...user,
|
||||
post_button: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await routeHandler(context);
|
||||
checkContextError(context);
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
describe('admin/users', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('admin/users');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should create a new user', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password, {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(!!newUser).toBe(true);
|
||||
expect(!!newUser.id).toBe(true);
|
||||
expect(!!newUser.is_admin).toBe(false);
|
||||
expect(!!newUser.email).toBe(true);
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.must_set_password).toBe(0);
|
||||
|
||||
const userModel = models().user();
|
||||
const userFromModel: User = await userModel.load(newUser.id);
|
||||
|
||||
expect(!!userFromModel.password).toBe(true);
|
||||
expect(userFromModel.password === password).toBe(false); // Password has been hashed
|
||||
});
|
||||
|
||||
test('should create a user with null properties if they are not explicitly set', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com');
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.can_share_folder).toBe(null);
|
||||
expect(newUser.can_share_note).toBe(null);
|
||||
expect(newUser.max_total_item_size).toBe(null);
|
||||
});
|
||||
|
||||
test('should ask user to set password if not set on creation', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com', '', {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.must_set_password).toBe(1);
|
||||
expect(!!newUser.password).toBe(true);
|
||||
});
|
||||
|
||||
test('should format the email when saving it', async function() {
|
||||
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
|
||||
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, email, password);
|
||||
const loggedInUser = await models().user().login(email, password);
|
||||
expect(!!loggedInUser).toBe(true);
|
||||
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
|
||||
});
|
||||
|
||||
test('should not create anything if user creation fail', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const userModel = models().user();
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
|
||||
const beforeUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(2);
|
||||
|
||||
try {
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const afterUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(afterUserCount);
|
||||
});
|
||||
|
||||
test('should list users', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1, true);
|
||||
const { user: user2 } = await createUserAndSession(2, false);
|
||||
|
||||
const result = await execRequest(session1.id, 'GET', 'admin/users');
|
||||
expect(result).toContain(user1.email);
|
||||
expect(result).toContain(user2.email);
|
||||
});
|
||||
|
||||
test('should delete sessions when changing password', async function() {
|
||||
const { user, session, password } = await createUserAndSession(1);
|
||||
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
|
||||
expect(await models().session().count()).toBe(4);
|
||||
|
||||
await patchUser(session.id, {
|
||||
id: user.id,
|
||||
email: 'changed@example.com',
|
||||
password: 'hunter11hunter22hunter33',
|
||||
password2: 'hunter11hunter22hunter33',
|
||||
}, '/admin/users/me');
|
||||
|
||||
const sessions = await models().session().all();
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(session.id);
|
||||
});
|
||||
|
||||
test('should apply ACL', async function() {
|
||||
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
|
||||
|
||||
// admin user cannot make themselves a non-admin
|
||||
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
|
||||
|
||||
// cannot delete own user
|
||||
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `admin/users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
});
|
311
packages/server/src/routes/admin/users.ts
Normal file
311
packages/server/src/routes/admin/users.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import { SubPath, redirect } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { contextSessionId, formParse } from '../../utils/requestUtils';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
|
||||
import config from '../../config';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
|
||||
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
|
||||
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
|
||||
import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl } from '../../utils/urlUtils';
|
||||
import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { formatDateTime, Hour } from '../../utils/time';
|
||||
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
|
||||
import { userFlagToString } from '../../models/UserFlagModel';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export interface CheckRepeatPasswordInput {
|
||||
password: string;
|
||||
password2: string;
|
||||
}
|
||||
|
||||
export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required: boolean): string {
|
||||
if (fields.password) {
|
||||
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
|
||||
return fields.password;
|
||||
} else {
|
||||
if (required) throw new ErrorUnprocessableEntity('Password is required');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function makeUser(isNew: boolean, fields: any): User {
|
||||
const user: User = {};
|
||||
|
||||
if ('email' in fields) user.email = fields.email;
|
||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
||||
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
||||
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
|
||||
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
|
||||
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
|
||||
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
|
||||
if ('account_type' in fields) user.account_type = Number(fields.account_type);
|
||||
|
||||
const password = checkRepeatPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
|
||||
if (!isNew) user.id = fields.id;
|
||||
|
||||
if (isNew) {
|
||||
user.must_set_password = user.password ? 0 : 1;
|
||||
user.password = user.password ? user.password : uuidgen();
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function defaultUser(): User {
|
||||
return {};
|
||||
}
|
||||
|
||||
function userIsNew(path: SubPath): boolean {
|
||||
return path.id === 'new';
|
||||
}
|
||||
|
||||
function userIsMe(path: SubPath): boolean {
|
||||
return path.id === 'me';
|
||||
}
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
|
||||
const userModel = ctx.joplin.models.user();
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
|
||||
|
||||
const users = await userModel.all();
|
||||
|
||||
users.sort((u1: User, u2: User) => {
|
||||
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
|
||||
if (u1.full_name && !u2.full_name) return +1;
|
||||
if (!u1.full_name && u2.full_name) return -1;
|
||||
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
const view: View = defaultView('admin/users', _('Users'));
|
||||
view.content = {
|
||||
users: users.map(user => {
|
||||
return {
|
||||
...user,
|
||||
url: adminUserUrl(user.id),
|
||||
displayName: user.full_name ? user.full_name : '(not set)',
|
||||
formattedItemMaxSize: formatMaxItemSize(user),
|
||||
formattedTotalSize: formatTotalSize(user),
|
||||
formattedMaxTotalSize: formatMaxTotalSize(user),
|
||||
formattedTotalSizePercent: formatTotalSizePercent(user),
|
||||
totalSizeClass: totalSizeClass(user),
|
||||
formattedAccountType: accountTypeToString(user.account_type),
|
||||
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
|
||||
rowClassName: user.enabled ? '' : 'is-disabled',
|
||||
};
|
||||
}),
|
||||
};
|
||||
return view;
|
||||
});
|
||||
|
||||
router.get('admin/users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
|
||||
const owner = ctx.joplin.owner;
|
||||
const isMe = userIsMe(path);
|
||||
const isNew = userIsNew(path);
|
||||
const models = ctx.joplin.models;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
user = !isNew ? user || await models.user().load(userId) : user;
|
||||
if (isNew && !user) user = defaultUser();
|
||||
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
|
||||
|
||||
let postUrl = '';
|
||||
|
||||
if (isNew) {
|
||||
postUrl = adminUserUrl('new');
|
||||
} else if (isMe) {
|
||||
postUrl = adminUserUrl('me');
|
||||
} else {
|
||||
postUrl = adminUserUrl(user.id);
|
||||
}
|
||||
|
||||
interface UserFlagView extends UserFlag {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
|
||||
return {
|
||||
...f,
|
||||
message: userFlagToString(f),
|
||||
};
|
||||
});
|
||||
|
||||
userFlagViews.sort((a, b) => {
|
||||
return a.created_time < b.created_time ? +1 : -1;
|
||||
});
|
||||
|
||||
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
|
||||
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
|
||||
|
||||
const view: View = defaultView('admin/user', _('Profile'));
|
||||
view.content.user = user;
|
||||
view.content.isNew = isNew;
|
||||
view.content.buttonTitle = isNew ? _('Create user') : _('Update profile');
|
||||
view.content.error = error;
|
||||
view.content.postUrl = postUrl;
|
||||
view.content.showDisableButton = !isNew && owner.id !== user.id && user.enabled;
|
||||
view.content.csrfTag = await createCsrfTag(ctx);
|
||||
|
||||
if (subscription) {
|
||||
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
||||
|
||||
view.content.subscription = subscription;
|
||||
view.content.showManageSubscription = !isNew;
|
||||
view.content.showUpdateSubscriptionBasic = !isNew && user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
|
||||
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
||||
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
|
||||
}
|
||||
|
||||
view.content.showImpersonateButton = !isNew && user.enabled && user.id !== owner.id;
|
||||
view.content.showRestoreButton = !isNew && !user.enabled;
|
||||
view.content.showScheduleDeletionButton = !isNew && !isScheduledForDeletion;
|
||||
view.content.showResetPasswordButton = !isNew && user.enabled;
|
||||
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
|
||||
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
|
||||
view.content.hasFlags = !!userFlagViews.length;
|
||||
view.content.userFlagViews = userFlagViews;
|
||||
view.content.stripePortalUrl = stripePortalUrl();
|
||||
view.content.pageTitle = view.content.buttonTitle;
|
||||
|
||||
view.jsFiles.push('zxcvbn');
|
||||
view.cssFiles.push('index/user');
|
||||
|
||||
if (config().accountTypesEnabled) {
|
||||
view.content.showAccountTypes = true;
|
||||
view.content.accountTypes = accountTypeOptions().map((o: any) => {
|
||||
o.selected = user.account_type === o.value;
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
});
|
||||
|
||||
router.alias(HttpMethod.POST, 'admin/users/:id', 'admin/users');
|
||||
|
||||
interface FormFields {
|
||||
id: Uuid;
|
||||
post_button: string;
|
||||
disable_button: string;
|
||||
restore_button: string;
|
||||
cancel_subscription_button: string;
|
||||
send_account_confirmation_email: string;
|
||||
update_subscription_basic_button: string;
|
||||
update_subscription_pro_button: string;
|
||||
impersonate_button: string;
|
||||
stop_impersonate_button: string;
|
||||
delete_user_flags: string;
|
||||
schedule_deletion_button: string;
|
||||
}
|
||||
|
||||
router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
|
||||
let user: User = {};
|
||||
const owner = ctx.joplin.owner;
|
||||
let userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
try {
|
||||
const body = await formParse(ctx.req);
|
||||
const fields = body.fields as FormFields;
|
||||
const isNew = userIsNew(path);
|
||||
if (userIsMe(path)) fields.id = userId;
|
||||
user = makeUser(isNew, fields);
|
||||
|
||||
const models = ctx.joplin.models;
|
||||
|
||||
if (fields.post_button) {
|
||||
const userToSave: User = models.user().fromApiInput(user);
|
||||
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
|
||||
if (isNew) {
|
||||
const savedUser = await models.user().save(userToSave);
|
||||
userId = savedUser.id;
|
||||
} else {
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
|
||||
// When changing the password, we also clear all session IDs for
|
||||
// that user, except the current one (otherwise they would be
|
||||
// logged out).
|
||||
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
|
||||
}
|
||||
} else if (fields.stop_impersonate_button) {
|
||||
await stopImpersonating(ctx);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (fields.disable_button || fields.restore_button) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
|
||||
} else if (fields.send_account_confirmation_email) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.impersonate_button) {
|
||||
await startImpersonating(ctx, userId);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else if (fields.schedule_deletion_button) {
|
||||
const deletionDate = Date.now() + 24 * Hour;
|
||||
|
||||
await models.userDeletion().add(userId, deletionDate, {
|
||||
processAccount: true,
|
||||
processData: true,
|
||||
});
|
||||
|
||||
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${adminUserDeletionsUrl()})`);
|
||||
} else if (fields.delete_user_flags) {
|
||||
const userFlagTypes: UserFlagType[] = [];
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (key.startsWith('user_flag_')) {
|
||||
const type = Number(key.substr(10));
|
||||
userFlagTypes.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
await models.userFlag().removeMulti(userId, userFlagTypes);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
|
||||
return redirect(ctx, adminUserUrl(userIsMe(path) ? '/me' : `/${userId}`));
|
||||
} catch (error) {
|
||||
error.message = `Error: Your changes were not saved: ${error.message}`;
|
||||
if (error instanceof ErrorForbidden) throw error;
|
||||
const endPoint = router.findEndPoint(HttpMethod.GET, 'admin/users/:id');
|
||||
return endPoint.handler(path, ctx, user, error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
@ -1,10 +1,10 @@
|
||||
import routeHandler from '../../middleware/routeHandler';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
|
||||
|
||||
describe('index_home', function() {
|
||||
describe('index/home', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('index_home');
|
||||
await beforeAllDb('index/home');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
@ -7,14 +7,14 @@ import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError, expectThrow } from '../../utils/testing/testUtils';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
|
||||
export async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
password = password === null ? uuidgen() : password;
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/users/new',
|
||||
url: '/admin/users/new',
|
||||
body: {
|
||||
email: email,
|
||||
password: password,
|
||||
@ -30,7 +30,7 @@ export async function postUser(sessionId: string, email: string, password: strin
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
export async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
@ -48,7 +48,7 @@ export async function patchUser(sessionId: string, user: any, url: string = ''):
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
export async function getUserHtml(sessionId: string, userId: string): Promise<string> {
|
||||
async function getUserHtml(sessionId: string, userId: string): Promise<string> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
@ -76,53 +76,6 @@ describe('index/users', function() {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should create a new user', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password, {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(!!newUser).toBe(true);
|
||||
expect(!!newUser.id).toBe(true);
|
||||
expect(!!newUser.is_admin).toBe(false);
|
||||
expect(!!newUser.email).toBe(true);
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.must_set_password).toBe(0);
|
||||
|
||||
const userModel = models().user();
|
||||
const userFromModel: User = await userModel.load(newUser.id);
|
||||
|
||||
expect(!!userFromModel.password).toBe(true);
|
||||
expect(userFromModel.password === password).toBe(false); // Password has been hashed
|
||||
});
|
||||
|
||||
test('should create a user with null properties if they are not explicitly set', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com');
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.can_share_folder).toBe(null);
|
||||
expect(newUser.can_share_note).toBe(null);
|
||||
expect(newUser.max_total_item_size).toBe(null);
|
||||
});
|
||||
|
||||
test('should ask user to set password if not set on creation', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com', '', {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.must_set_password).toBe(1);
|
||||
expect(!!newUser.password).toBe(true);
|
||||
});
|
||||
|
||||
test('new user should be able to login', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
@ -133,39 +86,6 @@ describe('index/users', function() {
|
||||
expect(loggedInUser.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
test('should format the email when saving it', async function() {
|
||||
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
|
||||
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, email, password);
|
||||
const loggedInUser = await models().user().login(email, password);
|
||||
expect(!!loggedInUser).toBe(true);
|
||||
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
|
||||
});
|
||||
|
||||
test('should not create anything if user creation fail', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const userModel = models().user();
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
|
||||
const beforeUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(2);
|
||||
|
||||
try {
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const afterUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(afterUserCount);
|
||||
});
|
||||
|
||||
test('should change user properties', async function() {
|
||||
const { user, session } = await createUserAndSession(1, false);
|
||||
|
||||
@ -198,15 +118,6 @@ describe('index/users', function() {
|
||||
expect((doc.querySelector('input[name=email]') as any).value).toBe('user1@localhost');
|
||||
});
|
||||
|
||||
test('should list users', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1, true);
|
||||
const { user: user2 } = await createUserAndSession(2, false);
|
||||
|
||||
const result = await execRequest(session1.id, 'GET', 'users');
|
||||
expect(result).toContain(user1.email);
|
||||
expect(result).toContain(user2.email);
|
||||
});
|
||||
|
||||
test('should allow user to set a password for new accounts', async function() {
|
||||
let user1 = await models().user().save({
|
||||
email: 'user1@localhost',
|
||||
@ -366,33 +277,31 @@ describe('index/users', function() {
|
||||
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
|
||||
});
|
||||
|
||||
test('should delete sessions when changing password', async function() {
|
||||
const { user, session, password } = await createUserAndSession(1);
|
||||
test('should not change non-whitelisted properties', async () => {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
||||
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
|
||||
expect(await models().session().count()).toBe(4);
|
||||
|
||||
await patchUser(session.id, {
|
||||
id: user.id,
|
||||
email: 'changed@example.com',
|
||||
password: 'hunter11hunter22hunter33',
|
||||
password2: 'hunter11hunter22hunter33',
|
||||
}, '/users/me');
|
||||
|
||||
const sessions = await models().session().all();
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(session.id);
|
||||
await patchUser(session1.id, {
|
||||
id: user1.id,
|
||||
is_admin: 1,
|
||||
max_item_size: 555,
|
||||
max_total_item_size: 5555,
|
||||
can_share_folder: 1,
|
||||
can_upload: 0,
|
||||
});
|
||||
const reloadedUser1 = await models().user().load(user1.id);
|
||||
expect(reloadedUser1.is_admin).toBe(0);
|
||||
expect(reloadedUser1.max_item_size).toBe(null);
|
||||
expect(reloadedUser1.max_total_item_size).toBe(null);
|
||||
expect(reloadedUser1.can_share_folder).toBe(null);
|
||||
expect(reloadedUser1.can_upload).toBe(1);
|
||||
});
|
||||
|
||||
test('should apply ACL', async function() {
|
||||
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
|
||||
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
||||
const { user: admin } = await createUserAndSession(1, true);
|
||||
const { session: session1 } = await createUserAndSession(2, false);
|
||||
|
||||
// non-admin cannot list users
|
||||
await expectHttpError(async () => execRequest(session1.id, 'GET', 'users'), ErrorForbidden.httpCode);
|
||||
await expectHttpError(async () => execRequest(session1.id, 'GET', 'admin/users'), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin user cannot view another user
|
||||
await expectHttpError(async () => execRequest(session1.id, 'GET', `users/${admin.id}`), ErrorForbidden.httpCode);
|
||||
@ -402,30 +311,6 @@ describe('index/users', function() {
|
||||
|
||||
// non-admin user cannot update another user
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, email: 'cantdothateither@example.com' }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin user cannot make themself an admin
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, is_admin: 1 }), ErrorForbidden.httpCode);
|
||||
|
||||
// admin user cannot make themselves a non-admin
|
||||
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
|
||||
|
||||
// only admins can delete users
|
||||
// Note: Disabled because the entire code is skipped if it's not an admin
|
||||
// await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
|
||||
|
||||
// cannot delete own user
|
||||
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change max_item_size
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_item_size: 1000 }), ErrorForbidden.httpCode);
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_total_item_size: 1000 }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change can_share_folder
|
||||
await models().user().save({ id: user1.id, can_share_folder: 0 });
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_share_folder: 1 }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change non-whitelisted properties
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_upload: 0 }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
|
||||
|
@ -4,24 +4,21 @@ import { RouteType } from '../../utils/types';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
|
||||
import { User, UserFlag, Uuid } from '../../services/database/types';
|
||||
import config from '../../config';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { NotificationKey } from '../../models/NotificationModel';
|
||||
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
|
||||
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
|
||||
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
|
||||
import { confirmUrl, stripePortalUrl, userDeletionsUrl } from '../../utils/urlUtils';
|
||||
import { cancelSubscriptionByUserId, updateCustomerEmail, updateSubscriptionType } from '../../utils/stripe';
|
||||
import { AccountType, accountTypeOptions } from '../../models/UserModel';
|
||||
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
|
||||
import { updateCustomerEmail } from '../../utils/stripe';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { formatDateTime, Hour } from '../../utils/time';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { cookieSet } from '../../utils/cookies';
|
||||
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
|
||||
import { userFlagToString } from '../../models/UserFlagModel';
|
||||
import { stopImpersonating } from '../admin/utils/users/impersonate';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export interface CheckRepeatPasswordInput {
|
||||
password: string;
|
||||
@ -39,120 +36,40 @@ export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required:
|
||||
return '';
|
||||
}
|
||||
|
||||
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function makeUser(isNew: boolean, fields: any): User {
|
||||
function makeUser(userId: Uuid, fields: any): User {
|
||||
const user: User = {};
|
||||
|
||||
if ('email' in fields) user.email = fields.email;
|
||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
||||
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
||||
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
|
||||
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
|
||||
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
|
||||
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
|
||||
if ('account_type' in fields) user.account_type = Number(fields.account_type);
|
||||
|
||||
const password = checkRepeatPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
|
||||
if (!isNew) user.id = fields.id;
|
||||
|
||||
if (isNew) {
|
||||
user.must_set_password = user.password ? 0 : 1;
|
||||
user.password = user.password ? user.password : uuidgen();
|
||||
}
|
||||
user.id = userId;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function defaultUser(): User {
|
||||
return {};
|
||||
}
|
||||
|
||||
function userIsNew(path: SubPath): boolean {
|
||||
return path.id === 'new';
|
||||
}
|
||||
|
||||
function userIsMe(path: SubPath): boolean {
|
||||
return path.id === 'me';
|
||||
}
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
||||
const userModel = ctx.joplin.models.user();
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
|
||||
|
||||
const users = await userModel.all();
|
||||
|
||||
users.sort((u1: User, u2: User) => {
|
||||
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
|
||||
if (u1.full_name && !u2.full_name) return +1;
|
||||
if (!u1.full_name && u2.full_name) return -1;
|
||||
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
const view: View = defaultView('users', 'Users');
|
||||
view.content = {
|
||||
users: users.map(user => {
|
||||
return {
|
||||
...user,
|
||||
displayName: user.full_name ? user.full_name : '(not set)',
|
||||
formattedItemMaxSize: formatMaxItemSize(user),
|
||||
formattedTotalSize: formatTotalSize(user),
|
||||
formattedMaxTotalSize: formatMaxTotalSize(user),
|
||||
formattedTotalSizePercent: formatTotalSizePercent(user),
|
||||
totalSizeClass: totalSizeClass(user),
|
||||
formattedAccountType: accountTypeToString(user.account_type),
|
||||
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
|
||||
rowClassName: user.enabled ? '' : 'is-disabled',
|
||||
};
|
||||
}),
|
||||
userDeletionUrl: userDeletionsUrl(),
|
||||
};
|
||||
return view;
|
||||
});
|
||||
|
||||
router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
|
||||
router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User = null, error: any = null) => {
|
||||
const owner = ctx.joplin.owner;
|
||||
const isMe = userIsMe(path);
|
||||
const isNew = userIsNew(path);
|
||||
const models = ctx.joplin.models;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
|
||||
|
||||
user = !isNew ? user || await models.user().load(userId) : null;
|
||||
if (isNew && !user) user = defaultUser();
|
||||
const models = ctx.joplin.models;
|
||||
const userId = owner.id;
|
||||
|
||||
const user = await models.user().load(userId);
|
||||
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
|
||||
|
||||
let postUrl = '';
|
||||
|
||||
if (isNew) {
|
||||
postUrl = `${config().baseUrl}/users/new`;
|
||||
} else if (isMe) {
|
||||
postUrl = `${config().baseUrl}/users/me`;
|
||||
} else {
|
||||
postUrl = `${config().baseUrl}/users/${user.id}`;
|
||||
}
|
||||
const postUrl = `${config().baseUrl}/users/me`;
|
||||
|
||||
interface UserFlagView extends UserFlag {
|
||||
message: string;
|
||||
}
|
||||
|
||||
let userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
|
||||
let userFlagViews: UserFlagView[] = (await models.userFlag().allByUserId(user.id)).map(f => {
|
||||
return {
|
||||
...f,
|
||||
message: userFlagToString(f),
|
||||
@ -165,35 +82,25 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
|
||||
|
||||
if (!owner.is_admin) userFlagViews = [];
|
||||
|
||||
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
|
||||
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
|
||||
const subscription = await ctx.joplin.models.subscription().byUserId(userId);
|
||||
|
||||
const view: View = defaultView('user', 'Profile');
|
||||
view.content.user = user;
|
||||
view.content.isNew = isNew;
|
||||
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
|
||||
view.content.user = formUser ? formUser : user;
|
||||
view.content.buttonTitle = _('Update profile');
|
||||
view.content.error = error;
|
||||
view.content.postUrl = postUrl;
|
||||
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
|
||||
view.content.csrfTag = await createCsrfTag(ctx);
|
||||
|
||||
if (subscription) {
|
||||
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
||||
|
||||
view.content.subscription = subscription;
|
||||
view.content.showManageSubscription = !isNew;
|
||||
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
|
||||
view.content.showUpdateSubscriptionBasic = user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = user.account_type !== AccountType.Pro;
|
||||
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
||||
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
|
||||
}
|
||||
|
||||
view.content.showImpersonateButton = !isNew && !!owner.is_admin && user.enabled && user.id !== owner.id;
|
||||
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
|
||||
view.content.showScheduleDeletionButton = !isNew && !!owner.is_admin && !isScheduledForDeletion;
|
||||
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
|
||||
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
|
||||
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
|
||||
view.content.hasFlags = !!userFlagViews.length;
|
||||
view.content.userFlagViews = userFlagViews;
|
||||
view.content.stripePortalUrl = stripePortalUrl();
|
||||
@ -303,98 +210,50 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
|
||||
interface FormFields {
|
||||
id: Uuid;
|
||||
post_button: string;
|
||||
disable_button: string;
|
||||
restore_button: string;
|
||||
cancel_subscription_button: string;
|
||||
send_account_confirmation_email: string;
|
||||
update_subscription_basic_button: string;
|
||||
update_subscription_pro_button: string;
|
||||
// user_cancel_subscription_button: string;
|
||||
impersonate_button: string;
|
||||
stop_impersonate_button: string;
|
||||
delete_user_flags: string;
|
||||
schedule_deletion_button: string;
|
||||
}
|
||||
|
||||
router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||
let user: User = {};
|
||||
const owner = ctx.joplin.owner;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
if (path.id && path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
|
||||
|
||||
const models = ctx.joplin.models;
|
||||
let user: User = null;
|
||||
|
||||
try {
|
||||
const body = await formParse(ctx.req);
|
||||
const fields = body.fields as FormFields;
|
||||
const isNew = userIsNew(path);
|
||||
if (userIsMe(path)) fields.id = userId;
|
||||
user = makeUser(isNew, fields);
|
||||
|
||||
const models = ctx.joplin.models;
|
||||
if (fields.id && fields.id !== owner.id) throw new ErrorForbidden();
|
||||
|
||||
user = makeUser(owner.id, fields);
|
||||
|
||||
if (fields.post_button) {
|
||||
const userToSave: User = models.user().fromApiInput(user);
|
||||
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Update, userToSave);
|
||||
|
||||
if (isNew) {
|
||||
await models.user().save(userToSave);
|
||||
} else {
|
||||
if (userToSave.email && !owner.is_admin) {
|
||||
await models.user().initiateEmailChange(userId, userToSave.email);
|
||||
delete userToSave.email;
|
||||
}
|
||||
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
|
||||
// When changing the password, we also clear all session IDs for
|
||||
// that user, except the current one (otherwise they would be
|
||||
// logged out).
|
||||
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
|
||||
if (userToSave.email && userToSave.email !== owner.email) {
|
||||
await models.user().initiateEmailChange(owner.id, userToSave.email);
|
||||
delete userToSave.email;
|
||||
}
|
||||
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
|
||||
// When changing the password, we also clear all session IDs for
|
||||
// that user, except the current one (otherwise they would be
|
||||
// logged out).
|
||||
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
|
||||
} else if (fields.stop_impersonate_button) {
|
||||
await stopImpersonating(ctx);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (owner.is_admin) {
|
||||
if (fields.disable_button || fields.restore_button) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
|
||||
} else if (fields.send_account_confirmation_email) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.impersonate_button) {
|
||||
await startImpersonating(ctx, userId);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else if (fields.schedule_deletion_button) {
|
||||
const deletionDate = Date.now() + 24 * Hour;
|
||||
|
||||
await models.userDeletion().add(userId, deletionDate, {
|
||||
processAccount: true,
|
||||
processData: true,
|
||||
});
|
||||
|
||||
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${userDeletionsUrl()})`);
|
||||
} else if (fields.delete_user_flags) {
|
||||
const userFlagTypes: UserFlagType[] = [];
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (key.startsWith('user_flag_')) {
|
||||
const type = Number(key.substr(10));
|
||||
userFlagTypes.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
await models.userFlag().removeMulti(userId, userFlagTypes);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
|
||||
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : `/${userId}`}`);
|
||||
return redirect(ctx, `${config().baseUrl}/users/me`);
|
||||
} catch (error) {
|
||||
error.message = `Error: Your changes were not saved: ${error.message}`;
|
||||
if (error instanceof ErrorForbidden) throw error;
|
||||
|
@ -5,12 +5,17 @@ import apiBatchItems from './api/batch_items';
|
||||
import apiDebug from './api/debug';
|
||||
import apiEvents from './api/events';
|
||||
import apiItems from './api/items';
|
||||
import apiLocks from './api/locks';
|
||||
import apiPing from './api/ping';
|
||||
import apiSessions from './api/sessions';
|
||||
import apiShares from './api/shares';
|
||||
import apiShareUsers from './api/share_users';
|
||||
import apiUsers from './api/users';
|
||||
import apiLocks from './api/locks';
|
||||
|
||||
import adminDashboard from './admin/dashboard';
|
||||
import adminTasks from './admin/tasks';
|
||||
import adminUserDeletions from './admin/user_deletions';
|
||||
import adminUsers from './admin/users';
|
||||
|
||||
import indexChanges from './index/changes';
|
||||
import indexHelp from './index/help';
|
||||
@ -24,44 +29,45 @@ import indexPrivacy from './index/privacy';
|
||||
import indexShares from './index/shares';
|
||||
import indexSignup from './index/signup';
|
||||
import indexStripe from './index/stripe';
|
||||
import indexTasks from './index/tasks';
|
||||
import indexTerms from './index/terms';
|
||||
import indexUpgrade from './index/upgrade';
|
||||
import indexUsers from './index/users';
|
||||
import indexUserDeletions from './index/user_deletions';
|
||||
|
||||
import defaultRoute from './default';
|
||||
|
||||
const routes: Routers = {
|
||||
'api/batch': apiBatch,
|
||||
'api/batch_items': apiBatchItems,
|
||||
'api/batch': apiBatch,
|
||||
'api/debug': apiDebug,
|
||||
'api/events': apiEvents,
|
||||
'api/items': apiItems,
|
||||
'api/locks': apiLocks,
|
||||
'api/ping': apiPing,
|
||||
'api/sessions': apiSessions,
|
||||
'api/share_users': apiShareUsers,
|
||||
'api/shares': apiShares,
|
||||
'api/users': apiUsers,
|
||||
'api/locks': apiLocks,
|
||||
|
||||
'admin/dashboard': adminDashboard,
|
||||
'admin/tasks': adminTasks,
|
||||
'admin/user_deletions': adminUserDeletions,
|
||||
'admin/users': adminUsers,
|
||||
|
||||
'changes': indexChanges,
|
||||
'help': indexHelp,
|
||||
'home': indexHome,
|
||||
'items': indexItems,
|
||||
'password': indexPassword,
|
||||
'login': indexLogin,
|
||||
'logout': indexLogout,
|
||||
'notifications': indexNotifications,
|
||||
'signup': indexSignup,
|
||||
'password': indexPassword,
|
||||
'privacy': indexPrivacy,
|
||||
'shares': indexShares,
|
||||
'users': indexUsers,
|
||||
'signup': indexSignup,
|
||||
'stripe': indexStripe,
|
||||
'terms': indexTerms,
|
||||
'privacy': indexPrivacy,
|
||||
'upgrade': indexUpgrade,
|
||||
'help': indexHelp,
|
||||
'tasks': indexTasks,
|
||||
'user_deletions': indexUserDeletions,
|
||||
'users': indexUsers,
|
||||
|
||||
'': defaultRoute,
|
||||
};
|
||||
|
@ -9,6 +9,19 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
|
||||
import MarkdownIt = require('markdown-it');
|
||||
import { headerAnchor } from '@joplin/renderer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { adminDashboardUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
|
||||
import { URL } from 'url';
|
||||
|
||||
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
|
||||
|
||||
export interface MenuItem {
|
||||
title: string;
|
||||
url?: string;
|
||||
children?: MenuItem[];
|
||||
selected?: boolean;
|
||||
icon?: string;
|
||||
selectedCondition?: MenuItemSelectedCondition;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
partials?: any;
|
||||
@ -48,6 +61,10 @@ interface GlobalParams {
|
||||
impersonatorAdminSessionId?: string;
|
||||
csrfTag?: string;
|
||||
s?: Record<string, string>; // List of translatable strings
|
||||
isAdminPage?: boolean;
|
||||
adminMenu?: MenuItem[];
|
||||
navbarMenu?: MenuItem[];
|
||||
currentUrl?: URL;
|
||||
}
|
||||
|
||||
export function isView(o: any): boolean {
|
||||
@ -95,6 +112,88 @@ export default class MustacheService {
|
||||
return `${config().layoutDir}/${name}.mustache`;
|
||||
}
|
||||
|
||||
private setSelectedMenu(selectedUrl: URL, menuItems: MenuItem[]) {
|
||||
if (!selectedUrl) return;
|
||||
if (!menuItems) return;
|
||||
|
||||
const url = stripOffQueryParameters(selectedUrl.href);
|
||||
|
||||
for (const menuItem of menuItems) {
|
||||
if (menuItem.url) {
|
||||
if (menuItem.selectedCondition) {
|
||||
menuItem.selected = menuItem.selectedCondition(selectedUrl);
|
||||
} else {
|
||||
menuItem.selected = url === menuItem.url;
|
||||
}
|
||||
}
|
||||
this.setSelectedMenu(selectedUrl, menuItem.children);
|
||||
}
|
||||
}
|
||||
|
||||
private makeAdminMenu(selectedUrl: URL): MenuItem[] {
|
||||
const output: MenuItem[] = [
|
||||
{
|
||||
title: _('General'),
|
||||
children: [
|
||||
{
|
||||
title: _('Dashboard'),
|
||||
url: adminDashboardUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Users'),
|
||||
url: adminUsersUrl(),
|
||||
},
|
||||
{
|
||||
title: _('User deletions'),
|
||||
url: adminUserDeletionsUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Tasks'),
|
||||
url: adminTasksUrl(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
this.setSelectedMenu(selectedUrl, output);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private makeNavbar(selectedUrl: URL, isAdmin: boolean): MenuItem[] {
|
||||
let output: MenuItem[] = [
|
||||
{
|
||||
title: _('Home'),
|
||||
url: homeUrl(),
|
||||
},
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
output = output.concat([
|
||||
{
|
||||
title: _('Items'),
|
||||
url: itemsUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Logs'),
|
||||
url: changesUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Admin'),
|
||||
url: adminDashboardUrl(),
|
||||
icon: 'fas fa-hammer',
|
||||
selectedCondition: (selectedUrl: URL) => {
|
||||
return selectedUrl.pathname.startsWith('/admin/') || selectedUrl.pathname === '/admin';
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.setSelectedMenu(selectedUrl, output);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private get defaultLayoutOptions(): GlobalParams {
|
||||
return {
|
||||
baseUrl: config().baseUrl,
|
||||
@ -187,7 +286,10 @@ export default class MustacheService {
|
||||
globalParams = {
|
||||
...this.defaultLayoutOptions,
|
||||
...globalParams,
|
||||
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
|
||||
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
|
||||
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
|
||||
isAdminPage: view.path.startsWith('/admin/'),
|
||||
s: {
|
||||
home: _('Home'),
|
||||
users: _('Users'),
|
||||
@ -196,6 +298,7 @@ export default class MustacheService {
|
||||
tasks: _('Tasks'),
|
||||
help: _('Help'),
|
||||
logout: _('Logout'),
|
||||
admin: _('Admin'),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -2,9 +2,11 @@ import { View } from '../services/MustacheService';
|
||||
|
||||
// Populate a View object with some good defaults.
|
||||
export default function(name: string, title: string): View {
|
||||
const pathPrefix = name.startsWith('admin/') ? '' : 'index/';
|
||||
|
||||
return {
|
||||
name: name,
|
||||
path: `index/${name}`,
|
||||
path: `${pathPrefix}/${name}`,
|
||||
content: {},
|
||||
navbar: true,
|
||||
title: title,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
||||
import { ItemAddressingType } from '../services/database/types';
|
||||
import { RouteType } from './types';
|
||||
import { expectThrow } from './testing/testUtils';
|
||||
|
||||
describe('routeUtils', function() {
|
||||
|
||||
@ -76,6 +77,9 @@ describe('routeUtils', function() {
|
||||
const actual = findMatchingRoute(path, routes);
|
||||
expect(actual).toEqual(expected);
|
||||
}
|
||||
|
||||
await expectThrow(async () => findMatchingRoute('help', routes));
|
||||
await expectThrow(async () => findMatchingRoute('api/users/123', routes));
|
||||
});
|
||||
|
||||
it('should split an item path', async function() {
|
||||
|
@ -223,6 +223,10 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
|
||||
// - The ID: "SOME_ID"
|
||||
// - The link: "content"
|
||||
export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
|
||||
// Enforce that path starts with "/" because if it doesn't, the function
|
||||
// will return strange but valid results.
|
||||
if (path.length && path[0] !== '/') throw new Error(`Expected path to start with "/": ${path}`);
|
||||
|
||||
const splittedPath = path.split('/');
|
||||
|
||||
// Because the path starts with "/", we remove the first element, which is
|
||||
|
@ -24,6 +24,7 @@ import uuidgen from '../uuidgen';
|
||||
import { createCsrfToken } from '../csrf';
|
||||
import { cookieSet } from '../cookies';
|
||||
import { parseEnv } from '../../env';
|
||||
import { URL } from 'url';
|
||||
|
||||
// Takes into account the fact that this file will be inside the /dist directory
|
||||
// when it runs.
|
||||
@ -218,7 +219,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
|
||||
query: req.query,
|
||||
method: req.method,
|
||||
redirect: () => {},
|
||||
URL: { origin: config().baseUrl },
|
||||
URL: new URL(config().baseUrl), // origin
|
||||
};
|
||||
|
||||
if (options.sessionId) {
|
||||
|
@ -144,6 +144,7 @@ export interface Config {
|
||||
tempDir: string;
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
adminBaseUrl: string;
|
||||
userContentBaseUrl: string;
|
||||
joplinAppBaseUrl: string;
|
||||
signupEnabled: boolean;
|
||||
|
@ -14,6 +14,14 @@ export function setQueryParameters(url: string, query: any): string {
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export function stripOffQueryParameters(url: string): string {
|
||||
const s = url.split('?');
|
||||
if (s.length <= 1) return url;
|
||||
|
||||
s.pop();
|
||||
return s.join('?');
|
||||
}
|
||||
|
||||
export function resetPasswordUrl(token: string): string {
|
||||
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
|
||||
}
|
||||
@ -42,14 +50,38 @@ export function homeUrl(): string {
|
||||
return `${config().baseUrl}/home`;
|
||||
}
|
||||
|
||||
export function itemsUrl(): string {
|
||||
return `${config().baseUrl}/items`;
|
||||
}
|
||||
|
||||
export function changesUrl(): string {
|
||||
return `${config().baseUrl}/changes`;
|
||||
}
|
||||
|
||||
export function loginUrl(): string {
|
||||
return `${config().baseUrl}/login`;
|
||||
}
|
||||
|
||||
export function userDeletionsUrl(): string {
|
||||
return `${config().baseUrl}/user_deletions`;
|
||||
export function adminUserDeletionsUrl(): string {
|
||||
return `${config().adminBaseUrl}/user_deletions`;
|
||||
}
|
||||
|
||||
export function userUrl(userId: Uuid): string {
|
||||
return `${config().baseUrl}/users/${userId}`;
|
||||
}
|
||||
|
||||
export function adminDashboardUrl(): string {
|
||||
return `${config().adminBaseUrl}/dashboard`;
|
||||
}
|
||||
|
||||
export function adminUsersUrl() {
|
||||
return `${config().adminBaseUrl}/users`;
|
||||
}
|
||||
|
||||
export function adminUserUrl(userId: string) {
|
||||
return `${config().adminBaseUrl}/users/${userId}`;
|
||||
}
|
||||
|
||||
export function adminTasksUrl() {
|
||||
return `${config().adminBaseUrl}/tasks`;
|
||||
}
|
||||
|
4
packages/server/src/views/admin/dashboard.mustache
Normal file
4
packages/server/src/views/admin/dashboard.mustache
Normal file
@ -0,0 +1,4 @@
|
||||
<h2 class="title">{{global.appName}} admin dashboard</h2>
|
||||
<div class="block readable-block">
|
||||
<p class="block">This is the admin dashboard. Please select an option on the left.</p>
|
||||
</div>
|
170
packages/server/src/views/admin/user.mustache
Normal file
170
packages/server/src/views/admin/user.mustache
Normal file
@ -0,0 +1,170 @@
|
||||
<h1 class="title">{{pageTitle}}</h1>
|
||||
|
||||
<form id="user_form" action="{{{postUrl}}}" method="POST" class="block">
|
||||
|
||||
<div class="block">
|
||||
{{> errorBanner}}
|
||||
{{{csrfTag}}}
|
||||
<input type="hidden" name="id" value="{{user.id}}"/>
|
||||
<input type="hidden" name="is_new" value="{{isNew}}"/>
|
||||
<div class="field">
|
||||
<label class="label">Full name</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="full_name" value="{{user.full_name}}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Email</label>
|
||||
<div class="control">
|
||||
<input class="input" type="email" name="email" value="{{user.email}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#showAccountTypes}}
|
||||
<div class="field">
|
||||
<label class="label">Account type</label>
|
||||
<div class="select">
|
||||
<select name="account_type">
|
||||
{{#accountTypes}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/accountTypes}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
|
||||
</div>
|
||||
{{/showAccountTypes}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max item size</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max total size</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Can share notebook</label>
|
||||
<div class="select">
|
||||
<select name="can_share_folder">
|
||||
{{#canShareFolderOptions}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/canShareFolderOptions}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Can upload</label>
|
||||
<div class="select">
|
||||
<select name="can_upload">
|
||||
{{#canUploadOptions}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/canUploadOptions}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<div class="control">
|
||||
<input id="password" class="input" type="password" name="password" autocomplete="new-password"/>
|
||||
</div>
|
||||
<p id="password_strength" class="help"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Repeat password</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password2" autocomplete="new-password"/>
|
||||
</div>
|
||||
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
|
||||
</div>
|
||||
|
||||
<div class="control block">
|
||||
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
|
||||
{{#showImpersonateButton}}
|
||||
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
|
||||
{{/showImpersonateButton}}
|
||||
{{#showResetPasswordButton}}
|
||||
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
|
||||
{{/showResetPasswordButton}}
|
||||
{{#showDisableButton}}
|
||||
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
|
||||
{{/showDisableButton}}
|
||||
{{#showRestoreButton}}
|
||||
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
|
||||
{{/showRestoreButton}}
|
||||
{{#showScheduleDeletionButton}}
|
||||
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
|
||||
{{/showScheduleDeletionButton}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#subscription}}
|
||||
<h1 class="title">Your subscription</h1>
|
||||
|
||||
<div class="block">
|
||||
<div class="control block">
|
||||
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
|
||||
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
|
||||
{{#showUpdateSubscriptionBasic}}
|
||||
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
|
||||
{{/showUpdateSubscriptionBasic}}
|
||||
{{#showUpdateSubscriptionPro}}
|
||||
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
|
||||
{{/showUpdateSubscriptionPro}}
|
||||
</div>
|
||||
</div>
|
||||
{{/subscription}}
|
||||
|
||||
</form>
|
||||
|
||||
{{#hasFlags}}
|
||||
<div class="content user-flags block">
|
||||
<h1 class="title">Flags</h1>
|
||||
<form action="{{{postUrl}}}" method="POST">
|
||||
{{{csrfTag}}}
|
||||
{{#userFlagViews}}
|
||||
<ul>
|
||||
<li><label class="checkbox"><input type="checkbox" name="user_flag_{{type}}"> {{message}}</label></li>
|
||||
</ul>
|
||||
{{/userFlagViews}}
|
||||
<input type="submit" name="delete_user_flags" class="button is-warning" value="Delete selected flags" />
|
||||
<p class="help">Note: normally it should not be needed to manually delete a flag because that's automatically handled by the system. So if it's necessary it means there's a bug that should be fixed.</p>
|
||||
</form>
|
||||
</div>
|
||||
{{/hasFlags}}
|
||||
|
||||
<script>
|
||||
$(() => {
|
||||
document.getElementById("user_form").addEventListener('submit', function(event) {
|
||||
if (event.submitter.getAttribute('name') === 'disable_button') {
|
||||
const ok = confirm('Disable this account?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'restore_button') {
|
||||
const ok = confirm('Restore this account?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
|
||||
const ok = confirm('Downgrade to Basic subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
|
||||
const ok = confirm('Upgrade to Pro subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
setupPasswordStrengthHandler();
|
||||
});
|
||||
</script>
|
60
packages/server/src/views/admin/users.mustache
Normal file
60
packages/server/src/views/admin/users.mustache
Normal file
@ -0,0 +1,60 @@
|
||||
<div class="block">
|
||||
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
|
||||
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Full name</th>
|
||||
<th>Email</th>
|
||||
<th>Account</th>
|
||||
<th>Max Item Size</th>
|
||||
<th>Total Size</th>
|
||||
<th>Max Total Size</th>
|
||||
<th>Can share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#users}}
|
||||
<tr class="{{rowClassName}}">
|
||||
<td><a href="{{{url}}}">{{displayName}}</a></td>
|
||||
<td>{{email}}</td>
|
||||
<td>{{formattedAccountType}}</td>
|
||||
<td>{{formattedItemMaxSize}}</td>
|
||||
<td class="{{totalSizeClass}}">{{formattedTotalSize}} ({{formattedTotalSizePercent}})</td>
|
||||
<td>{{formattedMaxTotalSize}}</td>
|
||||
<td>{{formattedCanShareFolder}}</td>
|
||||
</tr>
|
||||
{{/users}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="block">
|
||||
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
|
||||
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(() => {
|
||||
function toggleDisabled() {
|
||||
if ($('.hide-disabled').length) {
|
||||
$('.hide-disabled').addClass('show-disabled');
|
||||
$('.hide-disabled').removeClass('hide-disabled');
|
||||
$('.show-disabled').text('Show disabled');
|
||||
$('table tr.is-disabled').hide();
|
||||
} else {
|
||||
$('.show-disabled').addClass('hide-disabled');
|
||||
$('.show-disabled').removeClass('show-disabled');
|
||||
$('.hide-disabled').text('Hide disabled');
|
||||
$('table tr.is-disabled').show();
|
||||
}
|
||||
}
|
||||
|
||||
toggleDisabled();
|
||||
|
||||
$('.toggle-disabled-button').click(() => {
|
||||
toggleDisabled();
|
||||
});
|
||||
});
|
||||
</script>
|
@ -20,58 +20,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#global.owner.is_admin}}
|
||||
{{#showAccountTypes}}
|
||||
<div class="field">
|
||||
<label class="label">Account type</label>
|
||||
<div class="select">
|
||||
<select name="account_type">
|
||||
{{#accountTypes}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/accountTypes}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
|
||||
</div>
|
||||
{{/showAccountTypes}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max item size</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max total size</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Can share notebook</label>
|
||||
<div class="select">
|
||||
<select name="can_share_folder">
|
||||
{{#canShareFolderOptions}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/canShareFolderOptions}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Can upload</label>
|
||||
<div class="select">
|
||||
<select name="can_upload">
|
||||
{{#canUploadOptions}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/canUploadOptions}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{/global.owner.is_admin}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<div class="control">
|
||||
@ -85,28 +33,10 @@
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password2" autocomplete="new-password"/>
|
||||
</div>
|
||||
{{#global.owner.is_admin}}
|
||||
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
|
||||
{{/global.owner.is_admin}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control block">
|
||||
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
|
||||
{{#showImpersonateButton}}
|
||||
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
|
||||
{{/showImpersonateButton}}
|
||||
{{#showResetPasswordButton}}
|
||||
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
|
||||
{{/showResetPasswordButton}}
|
||||
{{#showDisableButton}}
|
||||
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
|
||||
{{/showDisableButton}}
|
||||
{{#showRestoreButton}}
|
||||
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
|
||||
{{/showRestoreButton}}
|
||||
{{#showScheduleDeletionButton}}
|
||||
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
|
||||
{{/showScheduleDeletionButton}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -114,33 +44,17 @@
|
||||
<h1 class="title">Your subscription</h1>
|
||||
|
||||
<div class="block">
|
||||
{{#global.owner.is_admin}}
|
||||
{{#showUpdateSubscriptionPro}}
|
||||
<div class="control block">
|
||||
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
|
||||
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
|
||||
{{#showUpdateSubscriptionBasic}}
|
||||
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
|
||||
{{/showUpdateSubscriptionBasic}}
|
||||
{{#showUpdateSubscriptionPro}}
|
||||
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
|
||||
{{/showUpdateSubscriptionPro}}
|
||||
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
|
||||
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
|
||||
</div>
|
||||
{{/global.owner.is_admin}}
|
||||
|
||||
{{^global.owner.is_admin}}
|
||||
{{#showUpdateSubscriptionPro}}
|
||||
<div class="control block">
|
||||
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
|
||||
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
|
||||
</div>
|
||||
{{/showUpdateSubscriptionPro}}
|
||||
{{#showManageSubscription}}
|
||||
<div class="control block">
|
||||
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
|
||||
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
|
||||
</div>
|
||||
{{/showManageSubscription}}
|
||||
{{/global.owner.is_admin}}
|
||||
{{/showUpdateSubscriptionPro}}
|
||||
|
||||
<div class="control block">
|
||||
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
|
||||
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/subscription}}
|
||||
|
||||
@ -164,28 +78,6 @@
|
||||
|
||||
<script>
|
||||
$(() => {
|
||||
document.getElementById("user_form").addEventListener('submit', function(event) {
|
||||
if (event.submitter.getAttribute('name') === 'disable_button') {
|
||||
const ok = confirm('Disable this account?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'restore_button') {
|
||||
const ok = confirm('Restore this account?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
|
||||
const ok = confirm('Downgrade to Basic subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
|
||||
const ok = confirm('Upgrade to Pro subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
setupPasswordStrengthHandler();
|
||||
});
|
||||
</script>
|
||||
|
@ -24,7 +24,14 @@
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
{{> notifications}}
|
||||
{{{contentHtml}}}
|
||||
|
||||
{{#global.isAdminPage}}
|
||||
{{> adminLayout}}
|
||||
{{/global.isAdminPage}}
|
||||
|
||||
{{^global.isAdminPage}}
|
||||
{{{contentHtml}}}
|
||||
{{/global.isAdminPage}}
|
||||
</div>
|
||||
</main>
|
||||
{{> footer}}
|
||||
|
20
packages/server/src/views/partials/adminLayout.mustache
Normal file
20
packages/server/src/views/partials/adminLayout.mustache
Normal file
@ -0,0 +1,20 @@
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
<div style="display: flex; margin-right: 3rem; background-color: #f5f5f5; padding: 1.5rem;">
|
||||
<aside class="menu">
|
||||
{{#global.adminMenu}}
|
||||
<p class="menu-label">{{title}}</p>
|
||||
<ul class="menu-list">
|
||||
{{#children}}
|
||||
<li><a href="{{{url}}}" class="{{#selected}}is-active{{/selected}}">{{title}}</a></li>
|
||||
{{/children}}
|
||||
</ul>
|
||||
{{/global.adminMenu}}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex: 1;">
|
||||
<div>
|
||||
{{{contentHtml}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -10,19 +10,9 @@
|
||||
{{#global.owner}}
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="{{{global.baseUrl}}}/home">{{global.s.home}}</a>
|
||||
{{#global.owner.is_admin}}
|
||||
<a class="navbar-item" href="{{{global.baseUrl}}}/users">{{global.s.users}}</a>
|
||||
{{/global.owner.is_admin}}
|
||||
{{#global.owner.is_admin}}
|
||||
<a class="navbar-item" href="{{{global.baseUrl}}}/items">{{global.s.items}}</a>
|
||||
{{/global.owner.is_admin}}
|
||||
{{#global.owner.is_admin}}
|
||||
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">{{global.s.log}}</a>
|
||||
{{/global.owner.is_admin}}
|
||||
{{#global.owner.is_admin}}
|
||||
<a class="navbar-item" href="{{{global.baseUrl}}}/tasks">{{global.s.tasks}}</a>
|
||||
{{/global.owner.is_admin}}
|
||||
{{#global.navbarMenu}}
|
||||
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i> {{/icon}}{{title}}</a>
|
||||
{{/global.navbarMenu}}
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
{{#global.isJoplinCloud}}
|
||||
|
Loading…
Reference in New Issue
Block a user