mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Server: Allow enabling or disabling a user. Handle cancelling subscription.
This commit is contained in:
parent
2b378880ce
commit
27c3cbdf8f
Binary file not shown.
@ -132,7 +132,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
||||
|
||||
const packageJson = await readPackageJson(`${rootDir}/package.json`);
|
||||
const stripePublicConfigs = JSON.parse(await readFile(`${rootDir}/stripeConfig.json`, 'utf8'));
|
||||
const stripePublicConfig = stripePublicConfigs[envType];
|
||||
const stripePublicConfig = stripePublicConfigs[envType === Env.BuildTypes ? Env.Dev : envType];
|
||||
if (!stripePublicConfig) throw new Error('Could not load Stripe config');
|
||||
|
||||
const viewDir = `${rootDir}/src/views`;
|
||||
|
@ -411,6 +411,7 @@ export interface Subscription {
|
||||
last_payment_failed_time?: number;
|
||||
updated_time?: string;
|
||||
created_time?: string;
|
||||
is_deleted?: number;
|
||||
}
|
||||
|
||||
export interface User extends WithDates, WithUuid {
|
||||
@ -427,6 +428,7 @@ export interface User extends WithDates, WithUuid {
|
||||
can_share_note?: number | null;
|
||||
max_total_item_size?: number | null;
|
||||
total_item_size?: number;
|
||||
enabled?: number;
|
||||
}
|
||||
|
||||
export const databaseSchema: DatabaseTables = {
|
||||
@ -564,6 +566,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
last_payment_failed_time: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
is_deleted: { type: 'number' },
|
||||
},
|
||||
users: {
|
||||
id: { type: 'string' },
|
||||
@ -582,6 +585,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
can_share_note: { type: 'number' },
|
||||
max_total_item_size: { type: 'string' },
|
||||
total_item_size: { type: 'string' },
|
||||
enabled: { type: 'number' },
|
||||
},
|
||||
};
|
||||
// AUTO-GENERATED-TYPES
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext } from '../utils/testing/testUtils';
|
||||
import { ErrorForbidden } from '../utils/errors';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext, models, expectHttpError } from '../utils/testing/testUtils';
|
||||
import ownerHandler from './ownerHandler';
|
||||
|
||||
describe('ownerHandler', function() {
|
||||
@ -44,4 +45,18 @@ describe('ownerHandler', function() {
|
||||
expect(!!context.joplin.owner).toBe(false);
|
||||
});
|
||||
|
||||
test('should not login if the user has been disabled', async function() {
|
||||
const { user, session } = await createUserAndSession(1);
|
||||
|
||||
await models().user().save({ id: user.id, enabled: 0 });
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: session.id,
|
||||
});
|
||||
|
||||
context.joplin.owner = null;
|
||||
|
||||
await expectHttpError(async () => ownerHandler(context, koaNext), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { AppContext, KoaNext } from '../utils/types';
|
||||
import { contextSessionId } from '../utils/requestUtils';
|
||||
import { ErrorForbidden } from '../utils/errors';
|
||||
|
||||
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||
const sessionId = contextSessionId(ctx, false);
|
||||
ctx.joplin.owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
|
||||
const owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
|
||||
if (owner && !owner.enabled) throw new ErrorForbidden('This user account is disabled. Please contact support.');
|
||||
ctx.joplin.owner = owner;
|
||||
return next();
|
||||
}
|
||||
|
@ -0,0 +1,16 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) {
|
||||
table.specificType('enabled', 'smallint').defaultTo(1).nullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('subscriptions', function(table: Knex.CreateTableBuilder) {
|
||||
table.specificType('is_deleted', 'smallint').defaultTo(0).nullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(_db: DbConnection): Promise<any> {
|
||||
|
||||
}
|
@ -11,7 +11,8 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
|
||||
public async checkIfAllowed(user: User, action: AclAction, resource: ShareUser = null): Promise<void> {
|
||||
if (action === AclAction.Create) {
|
||||
const recipient = await this.models().user().load(resource.user_id, { fields: ['account_type', 'can_share_folder'] });
|
||||
const recipient = await this.models().user().load(resource.user_id, { fields: ['account_type', 'can_share_folder', 'enabled'] });
|
||||
if (!recipient.enabled) throw new ErrorForbidden('the recipient account is disabled');
|
||||
if (!getCanShareFolder(recipient)) throw new ErrorForbidden('The sharing feature is not enabled for the recipient account');
|
||||
|
||||
const share = await this.models().share().load(resource.share_id);
|
||||
|
@ -43,11 +43,11 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
|
||||
}
|
||||
|
||||
public async byStripeSubscriptionId(id: string): Promise<Subscription> {
|
||||
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).first();
|
||||
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).where('is_deleted', '=', 0).first();
|
||||
}
|
||||
|
||||
public async byUserId(userId: Uuid): Promise<Subscription> {
|
||||
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).first();
|
||||
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).where('is_deleted', '=', 0).first();
|
||||
}
|
||||
|
||||
public async saveUserAndSubscription(email: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
|
||||
@ -71,4 +71,10 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
|
||||
});
|
||||
}
|
||||
|
||||
public async toggleSoftDelete(id: number, isDeleted: boolean) {
|
||||
const sub = await this.load(`${id}`);
|
||||
if (!sub) throw new Error(`No such subscription: ${id}`);
|
||||
await this.save({ id, is_deleted: isDeleted ? 1 : 0 });
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -96,6 +96,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
public async login(email: string, password: string): Promise<User> {
|
||||
const user = await this.loadByEmail(email);
|
||||
if (!user) return null;
|
||||
if (!user.enabled) throw new ErrorForbidden('This account is disabled. Please contact support if you need to re-activate it.');
|
||||
if (!auth.checkPassword(password, user.password)) return null;
|
||||
return user;
|
||||
}
|
||||
@ -230,6 +231,16 @@ export default class UserModel extends BaseModel<User> {
|
||||
return !!s[0].length && !!s[1].length;
|
||||
}
|
||||
|
||||
public async enable(id: Uuid, enabled: boolean) {
|
||||
const user = await this.load(id);
|
||||
if (!user) throw new ErrorNotFound(`No such user: ${id}`);
|
||||
await this.save({ id, enabled: enabled ? 1 : 0 });
|
||||
}
|
||||
|
||||
public async disable(id: Uuid) {
|
||||
await this.enable(id, false);
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
const shares = await this.models().share().sharesByUser(id);
|
||||
|
||||
|
@ -841,4 +841,20 @@ describe('shares.folder', function() {
|
||||
expect(share).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should check permissions - cannot share with a disabled account', async function() {
|
||||
const { session: session1 } = await createUserAndSession(1);
|
||||
const { user: user2, session: session2 } = await createUserAndSession(2);
|
||||
await models().user().disable(user2.id);
|
||||
|
||||
await expectHttpError(async () =>
|
||||
shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [
|
||||
{
|
||||
id: '000000000000000000000000000000F1',
|
||||
children: [],
|
||||
},
|
||||
]),
|
||||
ErrorForbidden.httpCode
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Share, ShareType } from '../../db';
|
||||
import routeHandler from '../../middleware/routeHandler';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
import { postApi } from '../../utils/testing/apiUtils';
|
||||
import { testImageBuffer } from '../../utils/testing/fileApiUtils';
|
||||
import { beforeAllDb, afterAllTests, parseHtml, beforeEachDb, createUserAndSession, koaAppContext, checkContextError, expectNotThrow, createNote, createItem } from '../../utils/testing/testUtils';
|
||||
import { beforeAllDb, afterAllTests, parseHtml, beforeEachDb, createUserAndSession, koaAppContext, checkContextError, expectNotThrow, createNote, createItem, models, expectHttpError } from '../../utils/testing/testUtils';
|
||||
|
||||
const resourceSize = 2720;
|
||||
|
||||
@ -159,4 +160,23 @@ describe('shares.link', function() {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('should throw an error if owner of share is disabled', async function() {
|
||||
const { user, session } = await createUserAndSession();
|
||||
|
||||
const noteItem = await createNote(session.id, {
|
||||
id: '00000000000000000000000000000001',
|
||||
body: 'testing',
|
||||
});
|
||||
|
||||
const share = await postApi<Share>(session.id, 'shares', {
|
||||
type: ShareType.Note,
|
||||
note_id: noteItem.jop_id,
|
||||
});
|
||||
|
||||
await models().user().disable(user.id);
|
||||
|
||||
await expectHttpError(async () => getShareContent(share.id), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { SubPath, ResponseType, Response } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { ErrorNotFound } from '../../utils/errors';
|
||||
import { ErrorForbidden, ErrorNotFound } from '../../utils/errors';
|
||||
import { Item, Share } from '../../db';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { FileViewerResponse, renderItem as renderJoplinItem } from '../../utils/joplinUtils';
|
||||
@ -29,6 +29,9 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
const share = await shareModel.load(path.id);
|
||||
if (!share) throw new ErrorNotFound();
|
||||
|
||||
const user = await ctx.joplin.models.user().load(share.owner_id);
|
||||
if (!user.enabled) throw new ErrorForbidden('This account has been disabled');
|
||||
|
||||
const itemModel = ctx.joplin.models.item();
|
||||
|
||||
const item = await itemModel.loadWithContent(share.item_id);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SubPath } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType, StripeConfig } from '../../utils/types';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { bodyFields } from '../../utils/requestUtils';
|
||||
import globalConfig from '../../config';
|
||||
@ -9,7 +9,7 @@ import { Stripe } from 'stripe';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import getRawBody = require('raw-body');
|
||||
import { AccountType } from '../../models/UserModel';
|
||||
const stripeLib = require('stripe');
|
||||
import { initStripe, stripeConfig } from '../../utils/stripe';
|
||||
|
||||
const logger = Logger.create('/stripe');
|
||||
|
||||
@ -17,14 +17,6 @@ const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.public = true;
|
||||
|
||||
function stripeConfig(): StripeConfig {
|
||||
return globalConfig().stripe;
|
||||
}
|
||||
|
||||
function initStripe(): Stripe {
|
||||
return stripeLib(stripeConfig().secretKey);
|
||||
}
|
||||
|
||||
async function stripeEvent(stripe: Stripe, req: any): Promise<Stripe.Event> {
|
||||
if (!stripeConfig().webhookSecret) throw new Error('webhookSecret is required');
|
||||
|
||||
@ -89,7 +81,7 @@ const postHandlers: Record<string, StripeRouteHandler> = {
|
||||
};
|
||||
},
|
||||
|
||||
// How to test the complete workflow locally:
|
||||
// # How to test the complete workflow locally
|
||||
//
|
||||
// - In website/build.ts, set the env to "dev", then build the website - `npm run watch-website`
|
||||
// - Start the Stripe CLI tool: `stripe listen --forward-to http://joplincloud.local:22300/stripe/webhook`
|
||||
@ -98,7 +90,11 @@ const postHandlers: Record<string, StripeRouteHandler> = {
|
||||
// - Start the workflow from http://localhost:8080/plans/
|
||||
// - The local website often is not configured to send email, but you can see them in the database, in the "emails" table.
|
||||
//
|
||||
// Stripe config:
|
||||
// # Simplified workflow
|
||||
//
|
||||
// To test without running the main website, use http://joplincloud.local:22300/stripe/checkoutTest
|
||||
//
|
||||
// # Stripe config
|
||||
//
|
||||
// - The public config is under packages/server/stripeConfig.json
|
||||
// - The private config is in the server .env file
|
||||
@ -221,11 +217,26 @@ const postHandlers: Record<string, StripeRouteHandler> = {
|
||||
await ctx.joplin.models.subscription().handlePayment(subId, false);
|
||||
},
|
||||
|
||||
'customer.subscription.deleted': async () => {
|
||||
// The subscription has been cancelled, either by us or directly
|
||||
// by the user. In that case, we disable the user.
|
||||
|
||||
const stripeSub = event.data.object as Stripe.Subscription;
|
||||
const sub = await ctx.joplin.models.subscription().byStripeSubscriptionId(stripeSub.id);
|
||||
if (!sub) throw new Error(`No subscription with ID: ${stripeSub.id}`);
|
||||
await ctx.joplin.models.user().enable(sub.user_id, false);
|
||||
await ctx.joplin.models.subscription().toggleSoftDelete(sub.id, true);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
if (hooks[event.type]) {
|
||||
logger.info(`Got Stripe event: ${event.type} [Handled]`);
|
||||
await hooks[event.type]();
|
||||
try {
|
||||
await hooks[event.type]();
|
||||
} catch (error) {
|
||||
logger.error(`Error processing event ${event.type}:`, event, error);
|
||||
}
|
||||
} else {
|
||||
logger.info(`Got Stripe event: ${event.type} [Unhandled]`);
|
||||
}
|
||||
@ -295,7 +306,7 @@ const getHandlers: Record<string, StripeRouteHandler> = {
|
||||
<body>
|
||||
<button id="checkout">Subscribe</button>
|
||||
<script>
|
||||
var PRICE_ID = 'price_1IvlmiLx4fybOTqJMKNZhLh2';
|
||||
var PRICE_ID = ${JSON.stringify(stripeConfig().basicPriceId)};
|
||||
|
||||
function handleResult() {
|
||||
console.info('Redirected to checkout');
|
||||
|
@ -340,10 +340,11 @@ describe('index/users', function() {
|
||||
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
|
||||
|
||||
// only admins can delete users
|
||||
await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { delete_button: true }), ErrorForbidden.httpCode);
|
||||
// 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}`, { delete_button: true }), ErrorForbidden.httpCode);
|
||||
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);
|
||||
|
@ -16,6 +16,7 @@ import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSize
|
||||
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
|
||||
import { yesNoDefaultOptions } from '../../utils/views/select';
|
||||
import { confirmUrl } from '../../utils/urlUtils';
|
||||
import { cancelSubscription } from '../../utils/stripe';
|
||||
|
||||
export interface CheckRepeatPasswordInput {
|
||||
password: string;
|
||||
@ -136,14 +137,18 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
|
||||
postUrl = `${config().baseUrl}/users/${user.id}`;
|
||||
}
|
||||
|
||||
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
|
||||
|
||||
const view: View = defaultView('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.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
|
||||
view.content.showResetPasswordButton = !isNew && owner.is_admin;
|
||||
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
|
||||
view.content.showCancelSubscription = !isNew && !!owner.is_admin && owner.id !== user.id && subscription;
|
||||
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
|
||||
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
|
||||
view.content.canSetEmail = isNew || owner.is_admin;
|
||||
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
|
||||
view.jsFiles.push('zxcvbn');
|
||||
@ -230,7 +235,9 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
|
||||
interface FormFields {
|
||||
id: Uuid;
|
||||
post_button: string;
|
||||
delete_button: string;
|
||||
disable_button: string;
|
||||
restore_button: string;
|
||||
cancel_subscription_button: string;
|
||||
send_reset_password_email: string;
|
||||
}
|
||||
|
||||
@ -256,16 +263,22 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||
} else {
|
||||
await userModel.save(userToSave, { isNew: false });
|
||||
}
|
||||
} else if (fields.delete_button) {
|
||||
const user = await userModel.load(path.id);
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.Delete, user);
|
||||
await userModel.delete(path.id);
|
||||
} else if (fields.send_reset_password_email) {
|
||||
const user = await userModel.load(path.id);
|
||||
await userModel.save({ id: user.id, must_set_password: 1 });
|
||||
await userModel.sendAccountConfirmationEmail(user);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
if (ctx.joplin.owner.is_admin) {
|
||||
if (fields.disable_button || fields.restore_button) {
|
||||
const user = await userModel.load(path.id);
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.Delete, user);
|
||||
await userModel.enable(path.id, !!fields.restore_button);
|
||||
} else if (fields.send_reset_password_email) {
|
||||
const user = await userModel.load(path.id);
|
||||
await userModel.save({ id: user.id, must_set_password: 1 });
|
||||
await userModel.sendAccountConfirmationEmail(user);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscription(ctx.joplin.models, userId);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : ''}`);
|
||||
|
21
packages/server/src/utils/stripe.ts
Normal file
21
packages/server/src/utils/stripe.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import globalConfig from '../config';
|
||||
import { StripeConfig } from './types';
|
||||
import { Stripe } from 'stripe';
|
||||
import { Uuid } from '../db';
|
||||
import { Models } from '../models/factory';
|
||||
const stripeLib = require('stripe');
|
||||
|
||||
export function stripeConfig(): StripeConfig {
|
||||
return globalConfig().stripe;
|
||||
}
|
||||
|
||||
export function initStripe(): Stripe {
|
||||
return stripeLib(stripeConfig().secretKey);
|
||||
}
|
||||
|
||||
export async function cancelSubscription(models: Models, userId: Uuid) {
|
||||
const sub = await models.subscription().byUserId(userId);
|
||||
if (!sub) throw new Error(`No subscription for user: ${userId}`);
|
||||
const stripe = initStripe();
|
||||
await stripe.subscriptions.del(sub.stripe_subscription_id);
|
||||
}
|
@ -37,14 +37,14 @@ export interface AppContext extends Koa.Context {
|
||||
// All the properties under `joplin` were previously at the root, so to make
|
||||
// sure they are no longer used anywhere we set them to "never", as that
|
||||
// would trigger the TypeScript compiler. Later on, all this can be removed.
|
||||
env: never;
|
||||
db: never;
|
||||
models: never;
|
||||
appLogger: never;
|
||||
notifications: never;
|
||||
owner: never;
|
||||
routes: never;
|
||||
services: never;
|
||||
// env: never;
|
||||
// db: never;
|
||||
// models: never;
|
||||
// appLogger: never;
|
||||
// notifications: never;
|
||||
// owner: never;
|
||||
// routes: never;
|
||||
// services: never;
|
||||
}
|
||||
|
||||
export enum DatabaseConfigClient {
|
||||
|
@ -80,17 +80,33 @@
|
||||
{{#showResetPasswordButton}}
|
||||
<input type="submit" name="send_reset_password_email" class="button is-warning" value="Send reset password email" />
|
||||
{{/showResetPasswordButton}}
|
||||
{{#showDeleteButton}}
|
||||
<input type="submit" name="delete_button" class="button is-danger" value="Delete" />
|
||||
{{/showDeleteButton}}
|
||||
{{#showDisableButton}}
|
||||
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
|
||||
{{/showDisableButton}}
|
||||
{{#showCancelSubscription}}
|
||||
<input type="submit" name="cancel_subscription_button" class="button is-danger" value="Cancel subscription" />
|
||||
{{/showCancelSubscription}}
|
||||
{{#showRestoreButton}}
|
||||
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
|
||||
{{/showRestoreButton}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
$(() => {
|
||||
document.getElementById("user_form").addEventListener('submit', function(event) {
|
||||
if (event.submitter.getAttribute('name') === 'delete_button') {
|
||||
const ok = confirm('Delete this user?');
|
||||
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') === 'cancel_subscription_button') {
|
||||
const ok = confirm('Cancel this subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
@ -13,6 +13,7 @@
|
||||
<th>Max Total Size</th>
|
||||
<th>Can share</th>
|
||||
<th>Is admin?</th>
|
||||
<th>Enabled?</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -27,6 +28,7 @@
|
||||
<td>{{formattedMaxTotalSize}}</td>
|
||||
<td>{{formattedCanShareFolder}}</td>
|
||||
<td>{{is_admin}}</td>
|
||||
<td>{{enabled}}</td>
|
||||
<td><a href="{{{global.baseUrl}}}/users/{{id}}" class="button is-primary is-small">Edit</a></td>
|
||||
</tr>
|
||||
{{/users}}
|
||||
|
Loading…
Reference in New Issue
Block a user