Server: Display banner when an account is disabled and provide reason

pull/5491/head^2
Laurent Cozic 2021-09-27 18:30:46 +01:00
parent 6fec2a93fc
commit 8c9331cf61
9 changed files with 78 additions and 14 deletions

View File

@ -1,5 +1,5 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { Notification } from '../services/database/types';
import { Notification, UserFlagType } from '../services/database/types';
import { defaultAdminEmail, defaultAdminPassword } from '../db';
import notificationHandler from './notificationHandler';
@ -76,4 +76,15 @@ describe('notificationHandler', function() {
expect(notifications.length).toBe(0);
});
test('should display a banner if the account is disabled', async function() {
const { session, user } = await createUserAndSession(1);
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
expect(ctx.joplin.notifications.find(v => v.id === 'accountDisabled')).toBeTruthy();
});
});

View File

@ -4,9 +4,10 @@ import { NotificationLevel } from '../services/database/types';
import { defaultAdminEmail, defaultAdminPassword } from '../db';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger';
import * as MarkdownIt from 'markdown-it';
import { NotificationKey } from '../models/NotificationModel';
import { profileUrl } from '../utils/urlUtils';
import { helpUrl, profileUrl } from '../utils/urlUtils';
import { userFlagToString } from '../models/UserFlagModel';
import renderMarkdown from '../utils/renderMarkdown';
const logger = Logger.create('notificationHandler');
@ -28,6 +29,34 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
}
}
// Special notification that cannot be dismissed.
async function handleUserFlags(ctx: AppContext): Promise<NotificationView> {
const user = ctx.joplin.owner;
const flags = await ctx.joplin.models.userFlag().allByUserId(ctx.joplin.owner.id);
const flagStrings = flags.map(f => `- ${userFlagToString(f)}`);
if (!user.enabled || !user.can_upload) {
return {
id: 'accountDisabled',
messageHtml: renderMarkdown(`Your account is disabled for the following reason(s):\n\n${flagStrings}\n\nPlease check the [help section](${helpUrl()}) for further information or contact support.`),
levelClassName: levelClassName(NotificationLevel.Error),
closeUrl: '',
};
} else if (flags.length) {
// Actually currently all flags result in either disabled upload or
// disabled account, but keeping that here anyway just in case.
return {
id: 'accountFlags',
messageHtml: renderMarkdown(`The following issues have been detected on your account:\n\n${flagStrings}\n\nPlease check the [help section](${helpUrl()}) for further information or contact support.`),
levelClassName: levelClassName(NotificationLevel.Important),
closeUrl: '',
};
}
return null;
}
// async function handleSqliteInProdNotification(ctx: AppContext) {
// if (!ctx.joplin.owner.is_admin) return;
@ -49,15 +78,13 @@ function levelClassName(level: NotificationLevel): string {
}
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
const markdownIt = new MarkdownIt();
const notificationModel = ctx.joplin.models.notification();
const notifications = await notificationModel.allUnreadByUserId(ctx.joplin.owner.id);
const views: NotificationView[] = [];
for (const n of notifications) {
views.push({
id: n.id,
messageHtml: markdownIt.render(n.message),
messageHtml: renderMarkdown(n.message),
levelClassName: levelClassName(n.level),
closeUrl: notificationModel.closeUrl(n.id),
});
@ -78,7 +105,12 @@ export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
await handleChangeAdminPasswordNotification(ctx);
// await handleSqliteInProdNotification(ctx);
ctx.joplin.notifications = await makeNotificationViews(ctx);
const notificationViews = await makeNotificationViews(ctx);
const userFlagView = await handleUserFlags(ctx);
if (userFlagView) notificationViews.push(userFlagView);
ctx.joplin.notifications = notificationViews;
} catch (error) {
logger.error(error);
}

View File

@ -1,5 +1,6 @@
import { isUniqueConstraintError } from '../db';
import { User, UserFlag, UserFlagType, Uuid } from '../services/database/types';
import { User, UserFlag, UserFlagType, userFlagTypeToLabel, Uuid } from '../services/database/types';
import { formatDateTime } from '../utils/time';
import BaseModel from './BaseModel';
interface AddRemoveOptions {
@ -12,6 +13,10 @@ function defaultAddRemoveOptions(): AddRemoveOptions {
};
}
export function userFlagToString(flag: UserFlag): string {
return `${userFlagTypeToLabel(flag.type)} on ${formatDateTime(flag.created_time)}`;
}
export default class UserFlagModels extends BaseModel<UserFlag> {
public get tableName(): string {
@ -86,7 +91,7 @@ export default class UserFlagModels extends BaseModel<UserFlag> {
// be set directly (except maybe in tests) - instead the appropriate user
// flags should be set, and this function will derive the enabled/can_upload
// properties from them.
private async updateUserFromFlags(userId: Uuid) {
public async updateUserFromFlags(userId: Uuid) {
const flags = await this.allByUserId(userId);
const user = await this.models().user().load(userId, { fields: ['id', 'can_upload', 'enabled'] });

View File

@ -4,7 +4,7 @@ import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types';
import { bodyFields, formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
import { User, UserFlagType, userFlagTypeToLabel, Uuid } from '../../services/database/types';
import { User, UserFlagType, Uuid } from '../../services/database/types';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
@ -21,6 +21,7 @@ import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime } from '../../utils/time';
import { cookieSet } from '../../utils/cookies';
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel';
export interface CheckRepeatPasswordInput {
password: string;
@ -146,7 +147,7 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
}
let userFlags: string[] = isNew ? null : (await models.userFlag().allByUserId(user.id)).map(f => {
return `${formatDateTime(f.created_time)}: ${userFlagTypeToLabel(f.type)}`;
return userFlagToString(f);
});
if (!userFlags || !userFlags.length || !owner.is_admin) userFlags = null;

View File

@ -5,6 +5,7 @@ export function markdownBodyToPlainText(md: string): string {
return md.replace(/\[.*\]\((.*)\)/g, '$1');
}
// TODO: replace with renderMarkdown()
export function markdownBodyToHtml(md: string): string {
const markdownIt = new MarkdownIt({
linkify: true,

View File

@ -82,6 +82,7 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
);
await models.user().save({ id: user.id, password });
await models.subscription().handlePayment(subscription.stripe_subscription_id, false);
await models.userFlag().add(user.id, UserFlagType.FailedPaymentWarning);
}
{

View File

@ -0,0 +1,8 @@
import MarkdownIt = require('markdown-it');
export default function(md: string): string {
const markdownIt = new MarkdownIt({
linkify: true,
});
return markdownIt.render(md);
}

View File

@ -22,11 +22,14 @@ export function forgotPasswordUrl(): string {
return `${config().baseUrl}/password/forgot`;
}
export function profileUrl(): string {
return `${config().baseUrl}/users/me`;
}
export function helpUrl(): string {
return `${config().baseUrl}/help`;
}
export function confirmUrl(userId: Uuid, validationToken: string): string {
return `${config().baseUrl}/users/${userId}/confirm?token=${validationToken}`;
}

View File

@ -1,7 +1,9 @@
{{#global.hasNotifications}}
{{#global.notifications}}
<div class="notification {{levelClassName}}" id="notification-{{id}}">
<button data-close-url="{{closeUrl}}" data-id="{{id}}" class="delete close-notification-button"></button>
<div class="notification {{levelClassName}} content" id="notification-{{id}}">
{{#closeUrl}}
<button data-close-url="{{closeUrl}}" data-id="{{id}}" class="delete close-notification-button"></button>
{{/closeUrl}}
{{{messageHtml}}}
</div>
{{/global.notifications}}