1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

35 Commits

Author SHA1 Message Date
Laurent Cozic
d8e24cff27 misc 2022-02-17 14:25:13 +00:00
Laurent Cozic
54c0df57b6 Merge branch 'dev' into server_organizations 2022-02-16 15:52:33 +00:00
Laurent Cozic
f27af09bc4 stripe 2022-02-15 19:24:39 +00:00
Laurent Cozic
9cae2a5fd0 disable for now 2022-02-15 14:59:10 +00:00
Laurent Cozic
cb6c402d59 short id 2022-02-14 18:24:09 +00:00
Laurent Cozic
5b5adccab3 Merge branch 'dev' into server_organizations 2022-02-14 16:36:51 +00:00
Laurent Cozic
8b6c4e2850 stripe 2022-02-05 12:47:24 +00:00
Laurent Cozic
5a08654f91 Merge branch 'dev' into server_organizations 2022-02-04 17:06:10 +00:00
Laurent Cozic
61928bee98 Server: Only use Stripe "customer.subscription.created" event to provision subscriptions 2022-02-04 16:36:34 +00:00
Laurent Cozic
a9f2da8bba stripe 2022-02-03 15:15:53 +00:00
Laurent Cozic
6ba1e0cb91 Merge branch 'dev' into server_organizations 2022-02-03 11:37:04 +00:00
Laurent Cozic
3d84efb9ed remove user from org 2022-01-31 18:49:50 +00:00
Laurent Cozic
81de2eb26a Merge branch 'dev' into server_organizations 2022-01-31 18:32:10 +00:00
Laurent Cozic
6082f8f869 update 2022-01-25 12:26:42 +00:00
Laurent Cozic
82101c9a0e Merge branch 'dev' into server_organizations 2022-01-25 10:33:37 +00:00
Laurent Cozic
6a4d0b9e79 update 2022-01-24 16:51:02 +00:00
Laurent Cozic
a5c78ebd36 debug tools 2022-01-22 17:52:11 +00:00
Laurent Cozic
d72c3b3b33 Merge branch 'dev' into server_organizations 2022-01-22 17:31:39 +00:00
Laurent Cozic
1a66488978 update 2022-01-21 16:59:40 +00:00
Laurent Cozic
24eda30d0a Merge branch 'dev' into server_organizations 2022-01-21 15:17:50 +00:00
Laurent Cozic
c0b93bbe32 updaet 2022-01-19 19:50:17 +00:00
Laurent Cozic
369165004a update 2022-01-19 15:38:05 +00:00
Laurent Cozic
595c14caaa Merge branch 'dev' into server_organizations 2022-01-19 14:41:24 +00:00
Laurent Cozic
bcf6f95a9f comment 2022-01-19 08:55:49 +00:00
Laurent Cozic
4bca995c23 invite 2022-01-18 15:46:45 +00:00
Laurent Cozic
0531165364 Merge branch 'dev' into server_organizations 2022-01-18 11:14:02 +00:00
Laurent Cozic
8bf6730a24 invite user 2022-01-17 18:36:55 +00:00
Laurent Cozic
ec800d1da3 Merge branch 'dev' into server_organizations 2022-01-17 16:25:21 +00:00
Laurent Cozic
d586502376 Doc: Update Joplin Cloud cancellation policy 2022-01-17 16:24:47 +00:00
Laurent Cozic
5de29dd4e9 views 2022-01-15 16:12:55 +00:00
Laurent Cozic
8aa417c0de Merge branch 'dev' into server_organizations 2022-01-14 10:20:30 +00:00
Laurent Cozic
2ac07566f8 Merge branch 'dev' into server_organizations 2022-01-11 15:12:55 +00:00
Laurent Cozic
4ff5d16658 update 2022-01-11 14:39:51 +00:00
Laurent Cozic
6af9f23cb5 update 2022-01-10 18:42:11 +00:00
Laurent Cozic
4338c2041f setup 2022-01-10 16:33:42 +00:00
66 changed files with 1700 additions and 143 deletions

View File

@@ -69,6 +69,10 @@ do
curl --data '{"action": "createUserDeletions"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
elif [[ $CMD == "createOrgs" ]]; then
curl --data '{"action": "createOrgs"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
elif [[ $CMD == "createData" ]]; then
echo 'mkbook "shared"' >> "$CMD_FILE"

View File

@@ -51,6 +51,7 @@
"query-string": "^6.8.3",
"rate-limiter-flexible": "^2.2.4",
"raw-body": "^2.4.1",
"short-uuid": "^4.2.0",
"sqlite3": "^4.1.0",
"stripe": "^8.150.0",
"uuid": "^8.3.2",

Binary file not shown.

View File

@@ -137,6 +137,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
storageDriverFallback: parseStorageDriverConnectionString(env.STORAGE_DRIVER_FALLBACK),
itemSizeHardLimit: 250000000, // Beyond this the Postgres driver will crash the app
maxTimeDrift: env.MAX_TIME_DRIFT,
organizationsEnabled: env.ORGANIZATIONS_ENABLED,
...overrides,
};
}

View File

@@ -90,6 +90,12 @@ const defaultEnvValues: EnvVariables = {
STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '',
// ==================================================
// Organization config
// ==================================================
ORGANIZATIONS_ENABLED: false,
// ==================================================
// User data deletion
// ==================================================
@@ -146,6 +152,8 @@ export interface EnvVariables {
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
ORGANIZATIONS_ENABLED: boolean;
USER_DATA_AUTO_DELETE_ENABLED: boolean;
USER_DATA_AUTO_DELETE_AFTER_DAYS: number;
}

View File

@@ -2,8 +2,10 @@ import { AppContext, KoaNext } from '../utils/types';
import { contextSessionId } from '../utils/requestUtils';
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
const models = ctx.joplin.models;
const sessionId = contextSessionId(ctx, false);
const owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
const owner = sessionId ? await models.session().sessionUser(sessionId) : null;
ctx.joplin.owner = owner;
ctx.joplin.organization = owner ? await models.organizations().userAssociatedOrganization(owner.id) : null;
return next();
}

View File

@@ -24,6 +24,7 @@ export default async function(ctx: AppContext) {
notifications: ctx.joplin.notifications || [],
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
owner: ctx.joplin.owner,
organization: ctx.joplin.organization,
supportEmail: config().supportEmail,
impersonatorAdminSessionId,
csrfTag: impersonatorAdminSessionId ? await createCsrfTag(ctx, false) : null,

View File

@@ -1,7 +1,7 @@
import { Knex } from 'knex';
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
import { hashPassword } from '../utils/auth';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('users', function(table: Knex.CreateTableBuilder) {

View File

@@ -0,0 +1,38 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('organizations', (table: Knex.CreateTableBuilder) => {
table.uuid('id').unique().notNullable();
table.string('name', 64).notNullable();
table.string('owner_id', 32).notNullable();
table.integer('max_users').defaultTo(1).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.createTable('organization_users', (table: Knex.CreateTableBuilder) => {
table.uuid('id').unique().notNullable();
table.uuid('organization_id').notNullable();
table.string('user_id', 32).defaultTo(null).nullable();
table.text('invitation_email', 'mediumtext').defaultTo('').notNullable();
table.integer('invitation_status').defaultTo(0).notNullable();
table.specificType('is_admin', 'smallint').defaultTo(0).nullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('organizations', (table: Knex.CreateTableBuilder) => {
table.index(['owner_id']);
});
await db.schema.alterTable('organization_users', (table: Knex.CreateTableBuilder) => {
table.unique(['user_id']);
table.index(['organization_id']);
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('organizations');
await db.schema.dropTable('organization_users');
}

View File

@@ -1,15 +1,16 @@
import { WithDates, WithUuid, databaseSchema, ItemType, Uuid, User } from '../services/database/types';
import { DbConnection, QueryContext } from '../db';
import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models, NewModelFactoryHandler } from './factory';
import * as EventEmitter from 'events';
import { Config } from '../utils/types';
import { Config, Env } from '../utils/types';
import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/personalizedUserContentBaseUrl';
import Logger from '@joplin/lib/Logger';
import dbuuid from '../utils/dbuuid';
import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination';
import { Knex } from 'knex';
import { unique } from '../utils/array';
const logger = Logger.create('BaseModel');
@@ -33,6 +34,10 @@ export interface LoadOptions {
fields?: string[];
}
export interface AllPaginatedOptions extends LoadOptions {
queryCallback?: (query: Knex.QueryBuilder)=> Knex.QueryBuilder;
}
export interface DeleteOptions {
validationRules?: any;
allowNoOp?: boolean;
@@ -85,6 +90,10 @@ export default abstract class BaseModel<T> {
return this.config_.userContentBaseUrl;
}
protected get env(): Env {
return this.config_.env;
}
protected personalizedUserContentBaseUrl(userId: Uuid): string {
return personalizedUserContentBaseUrl(userId, this.baseUrl, this.userContentBaseUrl);
}
@@ -109,6 +118,10 @@ export default abstract class BaseModel<T> {
return this.defaultFields_.slice();
}
protected get defaultFieldsWithPrefix(): string[] {
return this.defaultFields.map(f => `${this.tableName}.${f}`);
}
public static get eventEmitter(): EventEmitter {
if (!this.eventEmitter_) {
this.eventEmitter_ = new EventEmitter();
@@ -196,7 +209,7 @@ export default abstract class BaseModel<T> {
//
// The `name` argument is only for debugging, so that any stuck transaction
// can be more easily identified.
protected async withTransaction<T>(fn: Function, name: string): Promise<T> {
protected async withTransaction<T>(fn: Function, name: string = ''): Promise<T> {
const debugSteps = false;
const debugTimeout = true;
const timeoutMs = 10000;
@@ -238,7 +251,7 @@ export default abstract class BaseModel<T> {
return rows as T[];
}
public async allPaginated(pagination: Pagination, options: LoadOptions = {}): Promise<PaginatedResults<T>> {
public async allPaginated(pagination: Pagination, options: AllPaginatedOptions = {}): Promise<PaginatedResults<T>> {
pagination = {
...defaultPagination(),
...pagination,
@@ -246,12 +259,18 @@ export default abstract class BaseModel<T> {
const itemCount = await this.count();
const items = await this
let query = this
.db(this.tableName)
.select(this.selectFields(options))
.select(this.selectFields(options));
if (options.queryCallback) query = options.queryCallback(query);
query
.orderBy(pagination.order[0].by, pagination.order[0].dir)
.offset((pagination.page - 1) * pagination.limit)
.limit(pagination.limit) as T[];
.limit(pagination.limit);
const items = (await query) as T[];
return {
items,

View File

@@ -2,7 +2,7 @@ import { Uuid, Email, EmailSender } from '../services/database/types';
import BaseModel from './BaseModel';
export interface EmailToSend {
sender_id: EmailSender;
sender_id?: EmailSender;
recipient_email: string;
subject: string;
body: string;
@@ -28,6 +28,11 @@ export default class EmailModel extends BaseModel<Email> {
}
public async push(email: EmailToSend): Promise<Email | null> {
email = {
sender_id: EmailSender.NoReply,
...email,
};
if (email.key) {
const existingEmail = await this.byRecipientAndKey(email.recipient_email, email.key);
if (existingEmail) return null; // noop - the email has already been sent

View File

@@ -3,7 +3,7 @@ import { Uuid } from '../services/database/types';
import { LockType, Lock, LockClientType, defaultLockTtl, activeLock } from '@joplin/lib/services/synchronizer/LockHandler';
import { Value } from './KeyValueModel';
import { ErrorConflict, ErrorUnprocessableEntity } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
export default class LockModel extends BaseModel<Lock> {

View File

@@ -1,6 +1,6 @@
import { Notification, NotificationLevel, Uuid } from '../services/database/types';
import { ErrorUnprocessableEntity } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
import BaseModel, { ValidateOptions } from './BaseModel';
export enum NotificationKey {

View File

@@ -0,0 +1,198 @@
import { Organization, OrganizationUserInvitationStatus, UserFlagType, Uuid } from '../services/database/types';
import { ErrorBadRequest, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
import { beforeAllDb, afterAllTests, beforeEachDb, models, createUser, expectHttpError } from '../utils/testing/testUtils';
import { organizationInvitationConfirmUrl } from '../utils/urlUtils';
import { AccountType } from './UserModel';
const createOrg = async (props: Organization = null, orgNum: number = 1) => {
const orgOwner = await createUser(1000 + orgNum);
await models().user().save({
id: orgOwner.id,
account_type: AccountType.Pro,
});
await models().organizations().save({
name: 'testorg',
max_users: 10,
owner_id: orgOwner.id,
...props,
});
return models().organizations().userAssociatedOrganization(orgOwner.id);
};
const addUsers = async (orgId: Uuid, orgNum: number = 1) => {
await models().organizations().addUsers(orgId, [
`orguser${orgNum}-1@example.com`,
`orguser${orgNum}-2@example.com`,
]);
};
describe('OrganizationModel', function() {
beforeAll(async () => {
await beforeAllDb('OrganizationModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create an organization', async () => {
const org = await createOrg();
const owner = (await models().user().all())[0];
expect(org.name).toBe('testorg');
expect(org.max_users).toBe(10);
expect(org.owner_id).toBe(owner.id);
});
test('owner should be a Pro account', async () => {
const user = await createUser(1);
await expectHttpError(async () => models().organizations().save({
name: 'org',
owner_id: user.id,
}), ErrorUnprocessableEntity.httpCode);
});
test('should invite a user to the org', async () => {
const org = await createOrg();
await models().email().deleteAll();
const orgUser = await models().organizations().inviteUser(org.id, 'test@example.com');
expect(orgUser.organization_id).toBe(org.id);
expect(orgUser.invitation_email).toBe('test@example.com');
expect(orgUser.invitation_status).toBe(OrganizationUserInvitationStatus.Sent);
expect(orgUser.is_admin).toBe(0);
const email = (await models().email().all())[0];
expect(email.subject).toContain('testorg');
expect(email.recipient_email).toContain('test@example.com');
expect(email.body).toContain(organizationInvitationConfirmUrl(orgUser.id, ''));
});
test('should accept an invitation', async () => {
const org = await createOrg();
const orgUser = await models().organizations().inviteUser(org.id, 'test@example.com');
await models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Accepted);
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.account_type).toBe(AccountType.Pro);
expect(newUser.email_confirmed).toBe(1);
expect(newUser.must_set_password).toBe(1);
const orgUserMod = await models().organizationUsers().load(orgUser.id);
expect(orgUserMod.user_id).toBe(newUser.id);
expect(orgUserMod.invitation_status).toBe(OrganizationUserInvitationStatus.Accepted);
});
test('should reject an invitation', async () => {
const org = await createOrg();
const orgUser = await models().organizations().inviteUser(org.id, 'test@example.com');
const usersBefore = await models().user().count();
await models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Rejected);
const usersAfter = await models().user().count();
expect(usersAfter).toBe(usersBefore);
const orgUserMod = await models().organizationUsers().load(orgUser.id);
expect(orgUserMod.user_id).toBeFalsy();
expect(orgUserMod.invitation_status).toBe(OrganizationUserInvitationStatus.Rejected);
});
test('should check invitation status before responding', async () => {
const org = await createOrg();
const orgUser = await models().organizations().inviteUser(org.id, 'test@example.com');
await expectHttpError(async () => models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.None), ErrorBadRequest.httpCode);
await models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Accepted);
await expectHttpError(async () => models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Accepted), ErrorBadRequest.httpCode);
await expectHttpError(async () => models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Rejected), ErrorBadRequest.httpCode);
});
test('should retrieve the user associated organisations', async () => {
const org = await createOrg();
const owner = await models().user().load(org.owner_id);
{
const o = await models().organizations().userAssociatedOrganization(owner.id);
expect(o.id).toBe(org.id);
}
const randomUser = await createUser(1);
{
const o = await models().organizations().userAssociatedOrganization(randomUser.id);
expect(o).toBeFalsy();
}
const orgUser = await models().organizations().inviteUser(org.id, 'test@example.com');
await models().organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Accepted);
{
const u = await models().user().loadByEmail('test@example.com');
const o = await models().organizations().userAssociatedOrganization(u.id);
expect(o.id).toBe(org.id);
}
});
test('should not have a race condition when inviting users', async () => {
const org = await createOrg({ max_users: 10 });
const promises = [];
for (let i = 0; i < 100; i++) {
promises.push(models().organizations().inviteUser(org.id, `test${i}@example.com`));
}
try {
await Promise.allSettled(promises);
} catch (error) {
// Ignore
}
expect(await models().organizations().activeInvitationCount(org.id)).toBe(10);
});
test('should remove users', async () => {
const org = await createOrg({ max_users: 10 });
await addUsers(org.id);
expect((await models().organizationUsers().orgUsers(org.id)).length).toBe(2);
const orgUser = await models().organizationUsers().orgUserByEmail(org.id, 'orguser1-2@example.com');
await models().organizations().removeUser(org.id, orgUser.id);
expect((await models().organizationUsers().orgUsers(org.id)).length).toBe(1);
const removedUser = await models().user().load(orgUser.user_id);
expect(removedUser.enabled).toBe(0);
const flags = await models().userFlag().allByUserId(removedUser.id);
expect(flags.length).toBe(1);
expect(flags[0].type).toBe(UserFlagType.RemovedFromOrganization);
});
test('should check permissions when removing users', async () => {
const org1 = await createOrg({ max_users: 10 }, 1);
await addUsers(org1.id, 1);
const org2 = await createOrg({ max_users: 10 }, 2);
await addUsers(org2.id, 2);
{
const orgUser = await models().organizationUsers().orgUserByEmail(org1.id, 'orguser1-2@example.com');
await expectHttpError(async () => models().organizations().removeUser(org2.id, orgUser.id), ErrorForbidden.httpCode);
}
{
await expectHttpError(async () => models().organizations().removeUser(org2.id, 'notfound'), ErrorForbidden.httpCode);
}
});
});

View File

@@ -0,0 +1,175 @@
import { Organization, OrganizationUser, OrganizationUserInvitationStatus, User, UserFlagType, Uuid } from '../services/database/types';
import { ErrorBadRequest, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
import { organizationInvitationConfirmUrl } from '../utils/urlUtils';
import { uuidgen } from '../utils/uuid';
import { validateOrganizationMaxUsers } from '../utils/validation';
import orgInviteUserTemplate from '../views/emails/orgInviteUserTemplate';
import BaseModel, { AclAction, UuidType, ValidateOptions } from './BaseModel';
import { AccountType } from './UserModel';
export default class OrganizationModel extends BaseModel<Organization> {
public get tableName(): string {
return 'organizations';
}
protected uuidType(): UuidType {
return UuidType.Native;
}
public async checkIfAllowed(user: User, _action: AclAction, resource: Organization = null): Promise<void> {
if (user.is_admin) return;
if (resource.owner_id !== user.id) throw new ErrorForbidden();
}
public async byOwnerId(ownerId: Uuid): Promise<Organization | null> {
return this.db(this.tableName).where('owner_id', '=', ownerId).first();
}
public async userAssociatedOrganization(userId: Uuid): Promise<Organization | null> {
const org = await this.db(this.tableName).where('owner_id', '=', userId).first();
if (org) return org;
return this
.db('organization_users')
.leftJoin('organizations', 'organizations.id', 'organization_users.organization_id')
.select(this.defaultFieldsWithPrefix)
.where('organization_users.user_id', '=', userId)
.where('organization_users.invitation_status', '=', OrganizationUserInvitationStatus.Accepted)
.first();
}
protected async validate(object: Organization, options: ValidateOptions = {}): Promise<Organization> {
const org: Organization = await super.validate(object, options);
if ('owner_id' in org) {
const orgOwner = await this.models().user().load(org.owner_id, { fields: ['id', 'account_type'] });
if (!orgOwner) throw new ErrorUnprocessableEntity(`Organisation owner does not exist: ${org.owner_id}`);
if (orgOwner.account_type !== AccountType.Pro) throw new ErrorUnprocessableEntity(`Organisation owner must be a Pro account: ${org.owner_id}`);
}
if ('name' in org) {
if (!org.name) throw new ErrorUnprocessableEntity('Organisation name must not be empty');
}
if ('max_users' in org) {
validateOrganizationMaxUsers(org.max_users);
}
return org;
}
public async activeInvitationCount(orgId: Uuid): Promise<number> {
const r = await this
.db('organization_users')
.count('id', { as: 'item_count' })
.where('organization_id', '=', orgId)
.where('invitation_status', '!=', OrganizationUserInvitationStatus.Rejected);
return r[0].item_count;
}
public async addUsers(orgId: Uuid, emails: string[]): Promise<void> {
await this.withTransaction(async () => {
for (const email of emails) {
const orgUser = await this.inviteUser(orgId, email);
await this.respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Accepted);
}
});
}
public async inviteUser(orgId: Uuid, email: string) {
const user = await this.models().user().loadByEmail(email);
if (user) throw new ErrorUnprocessableEntity('This user already has a non-organisation account. Only users that are not already on the system can be invited.');
const org = await this.load(orgId);
if (!org) throw new Error(`No such organisation: ${orgId}`);
return this.withTransaction<OrganizationUser>(async () => {
// The `forUpdate` statement below means we take a lock on the
// organization row, which ensures no other routine is going to send
// any other invitation for this org while this transaction is
// active.
//
// Otherwise there would be a race condition that would allow going
// over max_users if multiple invitations are sent at the same time.
//
// That lock is released on commit or error.
//
// https://dba.stackexchange.com/a/167283/37012
await this.db(this.tableName).forUpdate().where('id', '=', orgId);
if (await this.activeInvitationCount(org.id) >= org.max_users) throw new ErrorBadRequest(`Cannot add more than ${org.max_users} users to this organisation.`);
const orgUser = await this.models().organizationUsers().save({
organization_id: orgId,
invitation_email: email,
invitation_status: OrganizationUserInvitationStatus.Sent,
is_admin: 0,
});
await this.models().email().push({
...orgInviteUserTemplate({
organizationName: org.name,
url: organizationInvitationConfirmUrl(orgUser.id, await this.models().token().generateAnonymous()),
}),
recipient_email: email,
});
return orgUser;
});
}
public async respondInvitation(orgUserId: Uuid, status: OrganizationUserInvitationStatus, password: string = null): Promise<User | null> {
const orgUser = await this.models().organizationUsers().load(orgUserId);
if (!orgUser) throw new ErrorBadRequest(`No such invitation: ${orgUserId}`);
if (![OrganizationUserInvitationStatus.Accepted, OrganizationUserInvitationStatus.Rejected].includes(status)) throw new ErrorBadRequest('Status can only be "accepted" or "rejected"');
if (orgUser.invitation_status !== OrganizationUserInvitationStatus.Sent) throw new ErrorBadRequest('Invitation status cannot be changed');
return this.withTransaction(async () => {
const newOrgUser: OrganizationUser = {
id: orgUserId,
invitation_status: status,
};
let createdUser: User = null;
if (status === OrganizationUserInvitationStatus.Accepted) {
createdUser = await this.models().user().save({
account_type: AccountType.Pro,
email: orgUser.invitation_email,
email_confirmed: 1,
must_set_password: 1,
password: password || uuidgen(),
});
newOrgUser.user_id = createdUser.id;
}
await this.models().organizationUsers().save(newOrgUser);
return createdUser;
});
}
public async removeUser(orgId: Uuid, orgUserId: Uuid) {
return this.removeUsers(orgId, [orgUserId]);
}
public async removeUsers(orgId: Uuid, orgUserIds: Uuid[]) {
const org = await this.load(orgId, { fields: ['id'] });
if (!org) throw new ErrorBadRequest(`No such org: ${orgId}`);
const orgUsers = await this.models().organizationUsers().loadByIds(orgUserIds, { fields: ['id', 'user_id', 'organization_id'] });
if (orgUsers.length !== orgUserIds.length) throw new ErrorForbidden('One or more users does not belong to the organization');
await this.withTransaction(async () => {
for (const orgUser of orgUsers) {
if (orgUser.organization_id !== org.id) throw new ErrorForbidden(`User ${orgUser.user_id} does not belong to organization ${org.id}`);
await this.models().organizationUsers().delete(orgUser.id);
await this.models().userFlag().add(orgUser.user_id, UserFlagType.RemovedFromOrganization);
}
});
}
}

View File

@@ -0,0 +1,61 @@
import { Organization, OrganizationUser, OrganizationUserInvitationStatus, User, Uuid } from '../services/database/types';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import BaseModel, { AclAction, LoadOptions, UuidType } from './BaseModel';
export default class OrganizationUserModel extends BaseModel<OrganizationUser> {
public get tableName(): string {
return 'organization_users';
}
protected uuidType(): UuidType {
return UuidType.Native;
}
public async orgUserByUserId(orgId: Uuid, userId: Uuid, options: LoadOptions = {}): Promise<OrganizationUser> {
return this.db(this.tableName)
.select(this.selectFields(options))
.where('organization_id', '=', orgId)
.where('user_id', '=', userId)
.first();
}
public async orgUsers(orgId: Uuid, options: LoadOptions = {}): Promise<OrganizationUser[]> {
return this.db(this.tableName)
.select(this.selectFields(options))
.where('organization_id', '=', orgId)
.where('user_id', '!=', '')
.where('invitation_status', '=', OrganizationUserInvitationStatus.Accepted);
}
public async orgUserByEmail(orgId: Uuid, email: Uuid) {
const user = await this.models().user().loadByEmail(email, { fields: ['id'] });
if (!user) throw new ErrorNotFound(`No such user: ${email}`);
return this.orgUserByUserId(orgId, user.id);
}
public async checkIfAllowed(user: User, action: AclAction, resource: OrganizationUser = null, organization: Organization = null): Promise<void> {
const getOrg = async () => {
if (organization) return organization;
organization = await this.models().organizations().load(resource.organization_id);
if (!organization) throw new Error(`Could not load organization: ${resource.organization_id}`);
return organization;
};
if (action === AclAction.Read) {
const org = await getOrg();
if (org.owner_id !== user.id) throw new ErrorForbidden('Cannot view users of this organisation');
}
if (action === AclAction.List) {
const org = await getOrg();
if (org.owner_id !== user.id) throw new ErrorForbidden('Cannot list the users of this organisation');
}
if (action === AclAction.Delete) {
const org = await getOrg();
if (resource.organization_id !== org.id) throw new ErrorForbidden('This user does not belong to this organization');
}
}
}

View File

@@ -1,6 +1,6 @@
import BaseModel from './BaseModel';
import { User, Session, Uuid } from '../services/database/types';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
import { ErrorForbidden } from '../utils/errors';
import { Hour } from '../utils/time';

View File

@@ -23,7 +23,8 @@ describe('SubscriptionModel', function() {
'Toto',
AccountType.Pro,
'STRIPE_USER_ID',
'STRIPE_SUB_ID'
'STRIPE_SUB_ID',
1
);
const user = await models().user().loadByEmail('toto@example.com');
@@ -40,13 +41,40 @@ describe('SubscriptionModel', function() {
expect(sub.user_id).toBe(user.id);
});
test('should also create an organization', async function() {
await models().subscription().saveUserAndSubscription(
'toto@example.com',
'Toto',
AccountType.Org,
'STRIPE_USER_ID',
'STRIPE_SUB_ID',
10
);
const user = await models().user().loadByEmail('toto@example.com');
const sub = await models().subscription().byStripeSubscriptionId('STRIPE_SUB_ID');
const org = await models().organizations().byOwnerId(user.id);
expect(user.account_type).toBe(AccountType.Pro);
expect(user.email).toBe('toto@example.com');
expect(sub.stripe_subscription_id).toBe('STRIPE_SUB_ID');
expect(sub.stripe_user_id).toBe('STRIPE_USER_ID');
expect(sub.user_id).toBe(user.id);
expect(org).toBeTruthy();
expect(org.owner_id).toBe(user.id);
expect(org.max_users).toBe(10);
});
test('should enable and allow the user to upload if a payment is successful', async function() {
let { user } = await models().subscription().saveUserAndSubscription(
'toto@example.com',
'Toto',
AccountType.Pro,
'STRIPE_USER_ID',
'STRIPE_SUB_ID'
'STRIPE_SUB_ID',
1
);
await models().user().save({

View File

@@ -1,11 +1,12 @@
import { Knex } from 'knex';
import { EmailSender, Subscription, User, UserFlagType, Uuid } from '../services/database/types';
import { EmailSender, Organization, Subscription, User, UserFlagType, Uuid } from '../services/database/types';
import { ErrorNotFound } from '../utils/errors';
import { Day } from '../utils/time';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
import paymentFailedTemplate from '../views/emails/paymentFailedTemplate';
import BaseModel from './BaseModel';
import { AccountType } from './UserModel';
import { _ } from '@joplin/lib/locale';
export const failedPaymentWarningInterval = 7 * Day;
export const failedPaymentFinalAccount = 14 * Day;
@@ -128,10 +129,10 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).where('is_deleted', '=', 0).first();
}
public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string, quantity: number) {
return this.withTransaction<UserAndSubscription>(async () => {
const user = await this.models().user().save({
account_type: accountType,
account_type: accountType === AccountType.Org ? AccountType.Pro : accountType,
email,
full_name: fullName,
email_confirmed: 0, // Email is not confirmed, because Stripe doesn't check this
@@ -146,7 +147,17 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
last_payment_time: Date.now(),
});
return { user, subscription };
let organization: Organization = null;
if (accountType === AccountType.Org) {
organization = await this.models().organizations().save({
name: _('New organisation'),
max_users: quantity,
owner_id: user.id,
});
}
return { user, subscription, organization };
}, 'SubscriptionModel::saveUserAndSubscription');
}

View File

@@ -1,6 +1,6 @@
import { Token, User, Uuid } from '../services/database/types';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import { uuidgen } from '../utils/uuid';
import BaseModel from './BaseModel';
export default class TokenModel extends BaseModel<Token> {
@@ -24,6 +24,15 @@ export default class TokenModel extends BaseModel<Token> {
return token.value;
}
public async generateAnonymous(): Promise<string> {
const token = await this.save({
value: uuidgen(32),
user_id: '',
});
return token.value;
}
public async checkToken(userId: string, tokenValue: string): Promise<void> {
if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token');
}

View File

@@ -109,6 +109,7 @@ export default class UserFlagModels extends BaseModel<UserFlag> {
const subscriptionCancelledFlag = flags.find(f => f.type === UserFlagType.SubscriptionCancelled);
const manuallyDisabledFlag = flags.find(f => f.type === UserFlagType.ManuallyDisabled);
const userDeletionInProgress = flags.find(f => f.type === UserFlagType.UserDeletionInProgress);
const removedFromOrganization = flags.find(f => f.type === UserFlagType.RemovedFromOrganization);
if (accountWithoutSubscriptionFlag) {
newProps.can_upload = 0;
@@ -138,6 +139,10 @@ export default class UserFlagModels extends BaseModel<UserFlag> {
newProps.enabled = 0;
}
if (removedFromOrganization) {
newProps.enabled = 0;
}
if (user.enabled !== newProps.enabled) {
newProps.disabled_time = !newProps.enabled ? Date.now() : 0;
}

View File

@@ -169,8 +169,8 @@ describe('UserModel', function() {
test('should disable upload and send an email if payment failed recently', async () => {
stripeConfig().enabled = true;
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111');
await models().subscription().saveUserAndSubscription('tutu@example.com', 'Tutu', AccountType.Basic, 'usr_222', 'sub_222');
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111', 1);
await models().subscription().saveUserAndSubscription('tutu@example.com', 'Tutu', AccountType.Basic, 'usr_222', 'sub_222', 1);
const sub = await models().subscription().byUserId(user1.id);
@@ -210,7 +210,7 @@ describe('UserModel', function() {
test('should disable disable the account and send an email if payment failed for good', async () => {
stripeConfig().enabled = true;
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111');
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111', 1);
const sub = await models().subscription().byUserId(user1.id);
@@ -239,7 +239,7 @@ describe('UserModel', function() {
test('should disable disable the account and send an email if payment failed for good', async () => {
stripeConfig().enabled = true;
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111');
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111', 1);
const sub = await models().subscription().byUserId(user1.id);

View File

@@ -1,4 +1,4 @@
import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel';
import BaseModel, { AclAction, LoadOptions, SaveOptions, ValidateOptions } from './BaseModel';
import { EmailSender, Item, NotificationLevel, Subscription, User, UserFlagType, Uuid } from '../services/database/types';
import * as auth from '../utils/auth';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound, ErrorBadRequest } from '../utils/errors';
@@ -27,6 +27,8 @@ import changeEmailConfirmationTemplate from '../views/emails/changeEmailConfirma
import changeEmailNotificationTemplate from '../views/emails/changeEmailNotificationTemplate';
import { NotificationKey } from './NotificationModel';
import prettyBytes = require('pretty-bytes');
import { validateEmail } from '../utils/validation';
import { Env } from '../utils/types';
const logger = Logger.create('UserModel');
@@ -41,6 +43,7 @@ export enum AccountType {
Default = 0,
Basic = 1,
Pro = 2,
Org = 3,
}
export interface Account {
@@ -50,34 +53,40 @@ export interface Account {
max_total_item_size: number;
}
const accountMetadata: Record<AccountType, Account> = {
[AccountType.Default]: {
account_type: AccountType.Default,
can_share_folder: 1,
max_item_size: 0,
max_total_item_size: 0,
},
[AccountType.Basic]: {
account_type: AccountType.Basic,
can_share_folder: 0,
max_item_size: 10 * MB,
max_total_item_size: 1 * GB,
},
[AccountType.Pro]: {
account_type: AccountType.Pro,
can_share_folder: 1,
max_item_size: 200 * MB,
max_total_item_size: 10 * GB,
},
[AccountType.Org]: {
account_type: AccountType.Org,
can_share_folder: 1,
max_item_size: 200 * MB,
max_total_item_size: 10 * GB,
},
};
interface AccountTypeSelectOptions {
value: number;
label: string;
}
export function accountByType(accountType: AccountType): Account {
const types: Account[] = [
{
account_type: AccountType.Default,
can_share_folder: 1,
max_item_size: 0,
max_total_item_size: 0,
},
{
account_type: AccountType.Basic,
can_share_folder: 0,
max_item_size: 10 * MB,
max_total_item_size: 1 * GB,
},
{
account_type: AccountType.Pro,
can_share_folder: 1,
max_item_size: 200 * MB,
max_total_item_size: 10 * GB,
},
];
const type = types.find(a => a.account_type === accountType);
const type = accountMetadata[accountType];
if (!type) throw new Error(`Invalid account type: ${accountType}`);
return type;
}
@@ -112,9 +121,11 @@ export default class UserModel extends BaseModel<User> {
return 'users';
}
public async loadByEmail(email: string): Promise<User> {
const user: User = this.formatValues({ email: email });
return this.db<User>(this.tableName).where(user).first();
public async loadByEmail(email: string, options: LoadOptions = {}): Promise<User> {
return this.db(this.tableName)
.select(this.selectFields(options))
.where(this.formatValues({ email: email }))
.first();
}
public async login(email: string, password: string): Promise<User> {
@@ -237,6 +248,8 @@ export default class UserModel extends BaseModel<User> {
}
private validatePassword(password: string) {
if (this.env === Env.Dev) return;
const result = zxcvbn(password);
if (result.score < 3) {
let msg: string[] = [result.feedback.warning];
@@ -263,18 +276,12 @@ export default class UserModel extends BaseModel<User> {
if ('email' in user) {
const existingUser = await this.loadByEmail(user.email);
if (existingUser && existingUser.id !== user.id) throw new ErrorUnprocessableEntity(`there is already a user with this email: ${user.email}`);
if (!this.validateEmail(user.email)) throw new ErrorUnprocessableEntity(`Invalid email: ${user.email}`);
validateEmail(user.email);
}
return super.validate(user, options);
}
private validateEmail(email: string): boolean {
const s = email.split('@');
if (s.length !== 2) return false;
return !!s[0].length && !!s[1].length;
}
// public async delete(id: string): Promise<void> {
// const shares = await this.models().share().sharesByUser(id);

View File

@@ -75,6 +75,8 @@ import { Config } from '../utils/types';
import LockModel from './LockModel';
import StorageModel from './StorageModel';
import UserDeletionModel from './UserDeletionModel';
import OrganizationModel from './OrganizationModel';
import OrganizationUserModel from './OrganizationUserModel';
import BackupItemModel from './BackupItemModel';
export type NewModelFactoryHandler = (db: DbConnection)=> Models;
@@ -171,6 +173,14 @@ export class Models {
return new UserDeletionModel(this.db_, this.newModelFactory, this.config_);
}
public organizations() {
return new OrganizationModel(this.db_, this.newModelFactory, this.config_);
}
public organizationUsers() {
return new OrganizationUserModel(this.db_, this.newModelFactory, this.config_);
}
public backupItem() {
return new BackupItemModel(this.db_, this.newModelFactory, this.config_);
}

View File

@@ -0,0 +1,182 @@
import defaultView from '../../utils/defaultView';
import Router from '../../utils/Router';
import { redirect, SubPath } from '../../utils/routeUtils';
import { AppContext, HttpMethod, RouteType } from '../../utils/types';
import { _ } from '@joplin/lib/locale';
import { View } from '../../services/MustacheService';
import { createCsrfTag } from '../../utils/csrf';
import { adminOrganizationsUrl, adminOrganizationUrl, adminUserUrl } from '../../utils/urlUtils';
import { bodyFields } from '../../utils/requestUtils';
import { Organization } from '../../services/database/types';
import { ErrorBadRequest, ErrorNotFound } from '../../utils/errors';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time';
import { organizationDefaultValues } from '../../services/database/defaultValues';
interface FormFields {
id: string;
name: string;
owner_email: string;
is_new: string;
max_users: string;
}
const defaultFields: FormFields = {
id: '',
name: '',
owner_email: '',
is_new: '0',
max_users: organizationDefaultValues().max_users.toString(),
};
const router: Router = new Router(RouteType.Web);
router.get('admin/organizations', async (_path: SubPath, ctx: AppContext) => {
const pagination = makeTablePagination(ctx.query, 'name', PaginationOrderDir.ASC);
const page = await ctx.joplin.models.organizations().allPaginated(pagination);
const owners = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.owner_id), { fields: ['id', 'email'] });
const table: Table = {
baseUrl: adminOrganizationsUrl(),
requestQuery: ctx.query,
pageCount: page.page_count,
pagination,
headers: [
// {
// name: 'select',
// label: '',
// canSort: false,
// },
{
name: 'name',
label: _('Name'),
stretch: true,
},
{
name: 'owner_id',
label: _('Owner'),
},
{
name: 'max_users',
label: _('Users'),
},
{
name: 'created_time',
label: _('Created'),
},
],
rows: page.items.map(d => {
const row: Row = [
// {
// value: `checkbox_${d.id}`,
// checkbox: true,
// },
{
value: d.name,
url: adminOrganizationUrl(d.id),
},
{
value: owners.find(o => o.id === d.owner_id)?.email,
url: adminUserUrl(d.owner_id),
},
{
value: d.max_users.toString(),
},
{
value: formatDateTime(d.created_time),
},
];
return row;
}),
};
const view = defaultView('admin/organizations', _('Organizations'));
view.content = {
organizationTable: makeTableView(table),
postUrl: adminOrganizationsUrl(),
csrfTag: await createCsrfTag(ctx),
};
// view.cssFiles = ['admin/organizations'];
return view;
});
router.post('admin/organizations/:id', async (path: SubPath, ctx: AppContext) => {
const models = ctx.joplin.models;
const fields = await bodyFields<FormFields>(ctx.req);
const isNew = path.id === 'new';
try {
const orgOwner = await models.user().loadByEmail(fields.owner_email);
if (!orgOwner) throw new ErrorBadRequest(_('No such user: %s', fields.owner_email));
const org: Organization = {
name: fields.name,
owner_id: orgOwner.id,
max_users: Number(fields.max_users),
};
if (!isNew) {
org.id = fields.id;
}
const savedOrg = await models.organizations().save(org);
await models.notification().addInfo(ctx.joplin.owner.id, isNew ? _('The new organisation has been created.') : _('The organisation has been saved.'));
return redirect(ctx, adminOrganizationUrl(savedOrg.id));
} catch (error) {
const endPoint = router.findEndPoint(HttpMethod.GET, 'admin/organizations/:id');
return endPoint.handler(path, ctx, fields, error);
}
});
router.get('admin/organizations/:id', async (path: SubPath, ctx: AppContext, fields: FormFields = null, error: any = null) => {
const models = ctx.joplin.models;
const isNew = path.id === 'new';
if (!fields && !isNew) {
const org = await models.organizations().load(path.id);
if (!org) throw new ErrorNotFound();
const orgOwner = await models.user().load(org.owner_id);
if (!orgOwner) await models.notification().addError(ctx.joplin.owner.id, `Cannot find organisation owner: ${orgOwner.id}`);
fields = {
...defaultFields,
id: org.id,
name: org.name,
owner_email: orgOwner ? orgOwner.email : '',
max_users: org.max_users.toString(),
};
}
if (!fields && isNew) {
fields = {
...defaultFields,
};
}
const view: View = {
...defaultView('admin/organization', _('Organisation')),
content: {
error,
fields,
csrfTag: await createCsrfTag(ctx),
organization: fields,
buttonTitle: isNew ? _('Create organisation') : _('Update organisation'),
postUrl: adminOrganizationUrl(path.id),
s: {
ownerEmailMustExist: _('Owner must exist in the system and must be a Pro account'),
},
},
};
return view;
});
export default router;

View File

@@ -2,7 +2,7 @@ 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 { uuidgen } from '../../utils/uuid';
import { ErrorForbidden } from '../../utils/errors';
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {

View File

@@ -10,7 +10,7 @@ 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 { uuidgen } from '../../utils/uuid';
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
@@ -18,7 +18,7 @@ import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl } from '../../util
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 { startImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel';
import { _ } from '@joplin/lib/locale';
@@ -185,7 +185,7 @@ router.get('admin/users/:id', async (path: SubPath, ctx: AppContext, user: User
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
}
view.content.showImpersonateButton = !isNew && user.enabled && user.id !== owner.id;
view.content.showImpersonateButton = !isNew && user.id !== owner.id;
view.content.showRestoreButton = !isNew && !user.enabled;
view.content.showScheduleDeletionButton = !isNew && !isScheduledForDeletion;
view.content.showResetPasswordButton = !isNew && user.enabled;
@@ -222,7 +222,7 @@ interface FormFields {
update_subscription_basic_button: string;
update_subscription_pro_button: string;
impersonate_button: string;
stop_impersonate_button: string;
// stop_impersonate_button: string;
delete_user_flags: string;
schedule_deletion_button: string;
}
@@ -232,6 +232,8 @@ router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
const owner = ctx.joplin.owner;
let userId = userIsMe(path) ? owner.id : path.id;
console.info('AAAAAAAAAAAAAAAAAAAAAAAAAAAA', ctx.URL);
try {
const body = await formParse(ctx.req);
const fields = body.fields as FormFields;
@@ -256,9 +258,9 @@ router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
// 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.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);
@@ -268,7 +270,7 @@ router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
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);
await startImpersonating(ctx, userId, ctx.URL.href);
return redirect(ctx, config().baseUrl);
} else if (fields.cancel_subscription_button) {
await cancelSubscriptionByUserId(models, userId);

View File

@@ -24,7 +24,7 @@ describe('users/impersonate', function() {
cookieSet(ctx, 'sessionId', adminSession.id);
await startImpersonating(ctx, user.id);
await startImpersonating(ctx, user.id, 'http://localhost');
{
expect(cookieGet(ctx, 'adminSessionId')).toBe(adminSession.id);
@@ -32,12 +32,13 @@ describe('users/impersonate', function() {
expect(sessionUser.id).toBe(user.id);
}
await stopImpersonating(ctx);
const returnUrl = await stopImpersonating(ctx);
{
expect(cookieGet(ctx, 'adminSessionId')).toBeFalsy();
const sessionUser = await models().session().sessionUser(cookieGet(ctx, 'sessionId'));
expect(sessionUser.id).toBe(adminUser.id);
expect(returnUrl).toBe('http://localhost');
}
});
@@ -49,18 +50,7 @@ describe('users/impersonate', function() {
cookieSet(ctx, 'sessionId', session.id);
await expectThrow(async () => startImpersonating(ctx, adminUser.id));
await expectThrow(async () => startImpersonating(ctx, adminUser.id, 'http://localhost'));
});
// test('should not stop impersonating if not admin', async function() {
// const ctx = await koaAppContext();
// await createUserAndSession(1, true);
// const { session } = await createUserAndSession(2);
// cookieSet(ctx, 'adminSessionId', session.id);
// await expectThrow(async () => stopImpersonating(ctx));
// });
});

View File

@@ -8,7 +8,7 @@ export function getImpersonatorAdminSessionId(ctx: AppContext): string {
return cookieGet(ctx, 'adminSessionId');
}
export async function startImpersonating(ctx: AppContext, userId: Uuid) {
export async function startImpersonating(ctx: AppContext, userId: Uuid, returnUrl: string) {
const adminSessionId = contextSessionId(ctx);
const user = await ctx.joplin.models.session().sessionUser(adminSessionId);
if (!user) throw new Error(`No user for session: ${adminSessionId}`);
@@ -16,18 +16,24 @@ export async function startImpersonating(ctx: AppContext, userId: Uuid) {
const impersonatedSession = await ctx.joplin.models.session().createUserSession(userId);
cookieSet(ctx, 'adminSessionId', adminSessionId);
cookieSet(ctx, 'impersonationReturnUrl', returnUrl);
cookieSet(ctx, 'sessionId', impersonatedSession.id);
}
export async function stopImpersonating(ctx: AppContext) {
export async function stopImpersonating(ctx: AppContext): Promise<string> {
const adminSessionId = cookieGet(ctx, 'adminSessionId');
if (!adminSessionId) throw new Error('Missing cookie adminSessionId');
const returnUrl = cookieGet(ctx, 'impersonationReturnUrl');
// This function simply moves the adminSessionId back to sessionId. There's
// no need to check if anything is valid because that will be done by other
// session checking routines. We also don't want this function to fail
// because it would leave the cookies in an invalid state (for example if
// the admin has lost their sessions, or the user no longer exists).
cookieDelete(ctx, 'adminSessionId');
cookieDelete(ctx, 'impersonationReturnUrl');
cookieSet(ctx, 'sessionId', adminSessionId);
return returnUrl;
}

View File

@@ -1,5 +1,5 @@
import config from '../../config';
import { clearDatabase, createTestUsers, CreateTestUsersOptions, createUserDeletions } from '../../tools/debugTools';
import { clearDatabase, createOrganizations, createTestUsers, CreateTestUsersOptions, createUserDeletions } from '../../tools/debugTools';
import { bodyFields } from '../../utils/requestUtils';
import Router from '../../utils/Router';
import { Env, RouteType } from '../../utils/types';
@@ -34,6 +34,10 @@ router.post('api/debug', async (_path: SubPath, ctx: AppContext) => {
await createTestUsers(ctx.joplin.db, config(), options);
}
if (query.action === 'createOrgs') {
await createOrganizations(ctx.joplin.db, config());
}
if (query.action === 'createUserDeletions') {
await createUserDeletions(ctx.joplin.db, config());
}

View File

@@ -6,7 +6,7 @@ import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { ErrorNotFound } from '../../utils/errors';
import { AclAction } from '../../models/BaseModel';
import uuidgen from '../../utils/uuidgen';
import { uuidgen } from '../../utils/uuid';
const router = new Router(RouteType.Api);

View File

@@ -28,6 +28,7 @@ router.get('home', async (_path: SubPath, ctx: AppContext) => {
if (ctx.method === 'GET') {
const user = ctx.joplin.owner;
const subscription = await ctx.joplin.models.subscription().byUserId(user.id);
const organization = ctx.joplin.organization;
const view = defaultView('home', 'Home');
view.content = {
@@ -74,6 +75,7 @@ router.get('home', async (_path: SubPath, ctx: AppContext) => {
betaExpiredDays: betaUserTrialPeriodDays(user.created_time, 0, 0),
betaStartSubUrl: betaStartSubUrl(user.email, user.account_type),
setupMessageHtml: setupMessageHtml(),
organization,
};
view.cssFiles = ['index/home'];

View File

@@ -8,6 +8,7 @@ import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
import { cookieSet } from '../../utils/cookies';
import { adminDashboardUrl, homeUrl } from '../../utils/urlUtils';
function makeView(error: any = null): View {
const view = defaultView('login', 'Login');
@@ -34,7 +35,13 @@ router.post('login', async (_path: SubPath, ctx: AppContext) => {
const session = await ctx.joplin.models.session().authenticate(body.fields.email, body.fields.password);
cookieSet(ctx, 'sessionId', session.id);
return redirect(ctx, `${config().baseUrl}/home`);
const owner = await ctx.joplin.models.user().load(session.user_id, { fields: ['id', 'is_admin'] });
if (owner.is_admin) {
return redirect(ctx, adminDashboardUrl());
} else {
return redirect(ctx, homeUrl());
}
} catch (error) {
return makeView(error);
}

View File

@@ -0,0 +1,104 @@
import { internalRedirect, redirect, SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { _ } from '@joplin/lib/locale';
import { ErrorNotFound } from '../../utils/errors';
import { AclAction } from '../../models/BaseModel';
import { createCsrfTag } from '../../utils/csrf';
import { homeUrl, organizationInvitationConfirmUrl, organizationUsersUrl } from '../../utils/urlUtils';
import { bodyFields } from '../../utils/requestUtils';
import { checkRepeatPassword } from './users';
import { cookieSet } from '../../utils/cookies';
import { NotificationKey } from '../../models/NotificationModel';
import { OrganizationUserInvitationStatus } from '../../services/database/types';
const router = new Router(RouteType.Web);
const getOrgUser = async (path: SubPath, ctx: AppContext) => {
const orgUser = await ctx.joplin.models.organizationUsers().load(path.id);
if (!orgUser) throw new ErrorNotFound();
await ctx.joplin.models.organizationUsers().checkIfAllowed(ctx.joplin.owner, AclAction.Read, orgUser);
return orgUser;
};
router.get('organization_users/:id', async (path: SubPath, ctx: AppContext) => {
const models = ctx.joplin.models;
const orgUser = await getOrgUser(path, ctx);
const user = await models.user().load(orgUser.user_id);
// TODO: move organizationUsersUrl to organization_users
const view: View = {
...defaultView('organizations/user', _('Organisation user')),
content: {
csrfTag: await createCsrfTag(ctx),
user,
orgUser,
postUrl: organizationUsersUrl('me'),
},
};
return view;
});
router.publicSchemas.push('organization_users/:id/confirm');
interface SetPasswordFormData {
token: string;
password: string;
password2: string;
}
router.get('organization_users/:id/confirm', async (path: SubPath, ctx: AppContext, _fields: SetPasswordFormData = null, error: any = null) => {
const models = ctx.joplin.models;
const orgUser = await models.organizationUsers().load(path.id);
const { token } = ctx.query;
const view: View = {
...defaultView('users/confirm', _('Organisation user')),
content: {
user: {
email: orgUser.invitation_email,
},
token,
error,
postUrl: organizationInvitationConfirmUrl(orgUser.id, token),
},
};
return view;
});
router.post('organization_users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
const orgUserId = path.id;
const models = ctx.joplin.models;
let fields: SetPasswordFormData = null;
try {
fields = await bodyFields<SetPasswordFormData>(ctx.req);
await models.token().checkToken('', fields.token);
const password = checkRepeatPassword(fields, true);
await models.token().deleteByValue('', fields.token);
const user = await models.organizations().respondInvitation(orgUserId, OrganizationUserInvitationStatus.Accepted, password);
const session = await models.session().createUserSession(user.id);
cookieSet(ctx, 'sessionId', session.id);
await models.notification().add(user.id, NotificationKey.PasswordSet);
return redirect(ctx, homeUrl());
} catch (error) {
return internalRedirect(path, ctx, router, 'organization_users/:id/confirm', fields, error);
}
});
export default router;

View File

@@ -0,0 +1,214 @@
import { internalRedirect, redirect, SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { _ } from '@joplin/lib/locale';
import { ErrorBadRequest, ErrorNotFound } from '../../utils/errors';
import { bodyFields } from '../../utils/requestUtils';
import { createCsrfTag } from '../../utils/csrf';
import { organizationUrl, organizationUsersUrl, organizationUserUrl } from '../../utils/urlUtils';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
import { Knex } from 'knex';
import { AclAction } from '../../models/BaseModel';
import { yesOrNo } from '../../utils/strings';
import { formatDateTime } from '../../utils/time';
import { organizationUserInvitationStatusToLabel } from '../../services/database/types';
import { validateEmail } from '../../utils/validation';
import { updateOrganizationCapacity } from '../../utils/stripe';
import config from '../../config';
const router = new Router(RouteType.Web);
interface OrganizationFormFields {
name: string;
max_users: string;
}
interface OrganizationUserFormFields {
emails?: string;
remove_user_button?: string;
invite_users_button?: string;
organization_user_id?: string;
}
// This method returns the organisation associated with the logged in user. It
// will throw an error if the user doesn't have one.
const getOrganizationIfAllowed = async (path: SubPath, ctx: AppContext) => {
if (path.id !== 'me') throw new ErrorNotFound();
const org = ctx.joplin.organization;
if (!org) throw new ErrorNotFound();
await ctx.joplin.models.organizations().checkIfAllowed(ctx.joplin.owner, AclAction.Read, org);
return org;
};
router.get('organizations/:id', async (path: SubPath, ctx: AppContext) => {
const org = await getOrganizationIfAllowed(path, ctx);
const fields: OrganizationFormFields = {
name: org.name,
max_users: org.max_users.toString(),
};
const view: View = {
...defaultView('organizations/index', _('Organisation')),
content: {
fields,
csrfTag: await createCsrfTag(ctx),
},
};
// view.cssFiles = ['index/changes'];
return view;
});
router.get('organizations/:id/users', async (path: SubPath, ctx: AppContext, fields: OrganizationUserFormFields = null, error: any = null) => {
const org = await getOrganizationIfAllowed(path, ctx);
const models = ctx.joplin.models;
await models.organizationUsers().checkIfAllowed(ctx.joplin.owner, AclAction.List, null, org);
const pagination = makeTablePagination(ctx.query, 'invitation_email', PaginationOrderDir.ASC);
const page = await models.organizationUsers().allPaginated(pagination, {
queryCallback: (query: Knex.QueryBuilder) => {
void query.where('organization_id', '=', org.id);
return query;
},
});
const users = await models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] });
const table: Table = {
baseUrl: organizationUrl('me'),
requestQuery: ctx.query,
pageCount: page.page_count,
pagination,
headers: [
{
name: 'select',
label: '',
canSort: false,
},
{
name: 'user_id',
label: _('User'),
},
{
name: 'invitation_email',
label: _('Invit. email'),
},
{
name: 'invitation_status',
label: _('Invit. status'),
},
{
name: 'is_admin',
label: _('Is admin?'),
},
{
name: 'created_time',
label: _('Added'),
},
],
rows: page.items.map(d => {
const user = users.find(u => u.id === d.user_id);
const row: Row = [
{
value: `checkbox_${d.id}`,
checkbox: true,
},
{
value: user?.full_name || user?.email,
url: organizationUserUrl(d.id),
},
{
value: d.invitation_email,
},
{
value: organizationUserInvitationStatusToLabel(d.invitation_status),
},
{
value: yesOrNo(d.is_admin),
},
{
value: formatDateTime(d.created_time),
},
];
return row;
}),
};
const view: View = {
...defaultView('organizations/users', _('Organisation users')),
content: {
organizationUserTable: makeTableView(table),
postUrl: organizationUsersUrl('me'),
updateCapacityUrl: organizationUrl('me'),
remainingInvitationCount: Math.max(org.max_users - await models.organizations().activeInvitationCount(org.id), 0),
fields,
error,
csrfTag: await createCsrfTag(ctx),
},
};
return view;
});
router.post('organizations/:id/users', async (path: SubPath, ctx: AppContext) => {
const org = await getOrganizationIfAllowed(path, ctx);
const models = ctx.joplin.models;
const owner = ctx.joplin.owner;
const fields = await bodyFields<OrganizationUserFormFields>(ctx.req);
try {
if (fields.invite_users_button) {
const emails = fields.emails.split(',').map(email => email.trim().toLowerCase());
emails.forEach(email => validateEmail(email));
for (const email of emails) {
await models.organizations().inviteUser(org.id, email);
}
} else if (fields.remove_user_button) {
const orgUser = await models.organizationUsers().load(fields.organization_user_id);
await models.organizationUsers().checkIfAllowed(owner, AclAction.Delete, orgUser, org);
await models.organizations().removeUser(org.id, orgUser.id);
await models.notification().addInfo(owner.id, _('User was successfully removed from the organisation'));
} else {
throw new ErrorBadRequest('No action provided');
}
return redirect(ctx, organizationUsersUrl('me'));
} catch (error) {
return internalRedirect(path, ctx, router, 'organizations/:id/users', fields, error);
}
});
router.post('organizations/:id', async (path: SubPath, ctx: AppContext) => {
const org = await getOrganizationIfAllowed(path, ctx);
const models = ctx.joplin.models;
const fields = await bodyFields<OrganizationFormFields>(ctx.req);
const maxUsers = Number(fields.max_users);
if (config().isJoplinCloud) {
if (org.max_users !== maxUsers) {
await updateOrganizationCapacity(models, org.id, maxUsers);
}
}
await models.organizations().save({
id: org.id,
name: fields.name,
max_users: maxUsers,
});
return redirect(ctx, organizationUrl('me'));
});
export default router;

View File

@@ -1,6 +1,6 @@
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, expectHttpError } from '../../utils/testing/testUtils';
import { execRequest } from '../../utils/testing/apiUtils';
import uuidgen from '../../utils/uuidgen';
import { uuidgen } from '../../utils/uuid';
import { ErrorNotFound } from '../../utils/errors';
describe('index/password', function() {

View File

@@ -5,7 +5,7 @@ import { MB } from '../../utils/bytes';
import { cookieGet } from '../../utils/cookies';
import { execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
import { uuidgen } from '../../utils/uuid';
import { FormUser } from './signup';
describe('index_signup', function() {

View File

@@ -4,7 +4,7 @@ import { AccountType } from '../../models/UserModel';
import { betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../../utils/stripe';
import { beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, expectNotThrow } from '../../utils/testing/testUtils';
import { AppContext } from '../../utils/types';
import uuidgen from '../../utils/uuidgen';
import { uuidgen } from '../../utils/uuid';
import { postHandlers } from './stripe';
interface StripeOptions {

View File

@@ -15,6 +15,7 @@ import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
import { Models } from '../../models/factory';
import { confirmUrl } from '../../utils/urlUtils';
import { msleep } from '../../utils/time';
import { organizationMaxUsers, organizationMinUsers } from '../../utils/validation';
const logger = Logger.create('/stripe');
@@ -60,10 +61,18 @@ async function getSubscriptionInfo(event: Stripe.Event, ctx: AppContext): Promis
return { sub, stripeSub };
}
export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, customerName: string, userEmail: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) => {
export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, customerName: string, userEmail: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string, quantity: number) => {
const existingUser = await models.user().loadByEmail(userEmail);
if (existingUser) {
if (accountType === AccountType.Org) {
// For now this is not supported. To review once Stripe
// auto-cancellation of subscriptions is disabled.
logger.info(`Cannot create an org with email ${existingUser.email} because the user already exist`);
await cancelSubscription(stripe, stripeSubscriptionId);
return;
}
const sub = await models.subscription().byUserId(existingUser.id);
if (!sub) {
@@ -106,14 +115,15 @@ export const handleSubscriptionCreated = async (stripe: Stripe, models: Models,
}
}
} else {
logger.info(`Creating subscription for new user: ${customerName} (${userEmail})`);
logger.info(`Creating subscription for new user: ${customerName} (${userEmail}), Account type: ${accountType}`);
await models.subscription().saveUserAndSubscription(
userEmail,
customerName,
accountType,
stripeUserId,
stripeSubscriptionId
stripeSubscriptionId,
quantity
);
}
};
@@ -141,6 +151,7 @@ export const postHandlers: PostHandlers = {
createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => {
const fields = await bodyFields<CreateCheckoutSessionFields>(ctx.req);
const priceId = fields.priceId;
const accountType = priceIdToAccountType(priceId);
const checkoutSession: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
@@ -165,6 +176,18 @@ export const postHandlers: PostHandlers = {
cancel_url: `${globalConfig().baseUrl}/stripe/cancel`,
};
if (accountType === AccountType.Org) {
checkoutSession.line_items[0] = {
...checkoutSession.line_items[0],
adjustable_quantity: {
enabled: true,
minimum: organizationMinUsers,
maximum: organizationMaxUsers,
},
quantity: organizationMinUsers,
};
}
if (fields.coupon) {
checkoutSession.discounts = [
{
@@ -233,6 +256,8 @@ export const postHandlers: PostHandlers = {
}
await models.keyValue().setValue(eventDoneKey, 1);
// console.info('EVENT', JSON.stringify(event, null, 4));
type HookFunction = ()=> Promise<void>;
const hooks: Record<string, HookFunction> = {
@@ -328,7 +353,8 @@ export const postHandlers: PostHandlers = {
customer.email,
accountType,
stripeUserId,
stripeSubscriptionId
stripeSubscriptionId,
stripeSub.items.data[0].quantity
);
},
@@ -378,8 +404,24 @@ export const postHandlers: PostHandlers = {
const user = await models.user().load(sub.user_id, { fields: ['id'] });
if (!user) throw new Error(`No such user: ${user.id}`);
logger.info(`Updating subscription of user ${user.id} to ${newAccountType}`);
await models.user().save({ id: user.id, account_type: newAccountType });
// If it's the owner of an organization, any change applies to
// the organization, not the user.
const org = await models.organizations().byOwnerId(user.id);
if (org) {
const newQuantity = stripeSub.items.data[0].quantity;
logger.info(`Updating subscription of organization ${org.id}. New quantity: ${newQuantity}`);
await models.organizations().save({
id: org.id,
max_users: newQuantity,
});
} else {
logger.info(`Updating subscription of user ${user.id} to ${newAccountType}`);
await models.user().save({
id: user.id,
account_type: newAccountType,
});
}
},
};
@@ -455,6 +497,7 @@ const getHandlers: Record<string, StripeRouteHandler> = {
const basicPrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly });
const proPrice = findPrice(stripeConfig().prices, { accountType: 2, period: PricePeriod.Monthly });
const orgPrice = findPrice(stripeConfig().prices, { accountType: 3, period: PricePeriod.Monthly });
const customPriceId = ctx.request.query.price_id;
@@ -486,10 +529,12 @@ const getHandlers: Record<string, StripeRouteHandler> = {
Promotion code: <input id="promotion_code" type="text"/> <br/>
<button id="checkout_basic">Subscribe Basic</button>
<button id="checkout_pro">Subscribe Pro</button>
<button id="checkout_org">Subscribe Org</button>
<button id="checkout_custom">Subscribe Custom</button>
<script>
var BASIC_PRICE_ID = ${JSON.stringify(basicPrice.id)};
var PRO_PRICE_ID = ${JSON.stringify(proPrice.id)};
var ORG_PRICE_ID = ${JSON.stringify(orgPrice.id)};
var CUSTOM_PRICE_ID = ${JSON.stringify(customPriceId)};
if (!CUSTOM_PRICE_ID) {
@@ -523,6 +568,11 @@ const getHandlers: Record<string, StripeRouteHandler> = {
createSessionAndRedirect(PRO_PRICE_ID);
});
document.getElementById("checkout_org").addEventListener("click", function(evt) {
evt.preventDefault();
createSessionAndRedirect(ORG_PRICE_ID);
});
document.getElementById("checkout_custom").addEventListener("click", function(evt) {
evt.preventDefault();
createSessionAndRedirect(CUSTOM_PRICE_ID);

View File

@@ -5,7 +5,7 @@ import { cookieGet } from '../../utils/cookies';
import { ErrorForbidden } from '../../utils/errors';
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';
import { uuidgen } from '../../utils/uuid';
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
password = password === null ? uuidgen() : password;

View File

@@ -247,8 +247,8 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
// 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);
const returnUrl = await stopImpersonating(ctx);
return redirect(ctx, returnUrl ? returnUrl : config().baseUrl);
} else {
throw new Error('Invalid form button');
}

View File

@@ -36,7 +36,7 @@
// import BaseController from '../BaseController';
// import mustacheService from '../../services/MustacheService';
// import { ErrorNotFound } from '../../utils/errors';
// import uuidgen from '../../utils/uuidgen';
// import { uuidgen } from '../../utils/uuid';
// import controllers from '../factory';
// export default class OAuthController extends BaseController {

View File

@@ -14,6 +14,7 @@ import apiUsers from './api/users';
import adminDashboard from './admin/dashboard';
import adminEmails from './admin/emails';
import adminOrganizations from './admin/organizations';
import adminTasks from './admin/tasks';
import adminUserDeletions from './admin/user_deletions';
import adminUsers from './admin/users';
@@ -25,6 +26,8 @@ import indexItems from './index/items';
import indexLogin from './index/login';
import indexLogout from './index/logout';
import indexNotifications from './index/notifications';
import indexOrganizations from './index/organizations';
import indexOrganizationUsers from './index/organization_users';
import indexPassword from './index/password';
import indexPrivacy from './index/privacy';
import indexShares from './index/shares';
@@ -51,6 +54,7 @@ const routes: Routers = {
'admin/dashboard': adminDashboard,
'admin/emails': adminEmails,
'admin/organizations': adminOrganizations,
'admin/tasks': adminTasks,
'admin/user_deletions': adminUserDeletions,
'admin/users': adminUsers,
@@ -62,6 +66,8 @@ const routes: Routers = {
'login': indexLogin,
'logout': indexLogout,
'notifications': indexNotifications,
'organizations': indexOrganizations,
'organization_users': indexOrganizationUsers,
'password': indexPassword,
'privacy': indexPrivacy,
'shares': indexShares,

View File

@@ -3,13 +3,13 @@ import * as fs from 'fs-extra';
import { extname } from 'path';
import config from '../config';
import { filename } from '@joplin/lib/path-utils';
import { NotificationView } from '../utils/types';
import { User } from '../services/database/types';
import { Config, NotificationView } from '../utils/types';
import { Organization, User } from '../services/database/types';
import { makeUrl, UrlType } from '../utils/routeUtils';
import MarkdownIt = require('markdown-it');
import { headerAnchor } from '@joplin/renderer';
import { _ } from '@joplin/lib/locale';
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
import { adminDashboardUrl, adminEmailsUrl, adminOrganizationsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, organizationUrl, stripOffQueryParameters } from '../utils/urlUtils';
import { URL } from 'url';
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
@@ -50,6 +50,7 @@ interface GlobalParams {
notifications?: NotificationView[];
hasNotifications?: boolean;
owner?: User;
organization?: Organization;
appVersion?: string;
appName?: string;
termsUrl?: string;
@@ -65,6 +66,7 @@ interface GlobalParams {
adminMenu?: MenuItem[];
navbarMenu?: MenuItem[];
currentUrl?: URL;
config?: Config;
}
export function isView(o: any): boolean {
@@ -159,12 +161,19 @@ export default class MustacheService {
},
];
if (config().organizationsEnabled) {
output[0].children.push({
title: _('Organisations'),
url: adminOrganizationsUrl(),
});
}
this.setSelectedMenu(selectedUrl, output);
return output;
}
private makeNavbar(selectedUrl: URL, isAdmin: boolean): MenuItem[] {
private makeNavbar(selectedUrl: URL, isAdmin: boolean, ownOrganization: boolean): MenuItem[] {
let output: MenuItem[] = [
{
title: _('Home'),
@@ -172,6 +181,13 @@ export default class MustacheService {
},
];
if (ownOrganization) {
output.push({
title: _('Organisation'),
url: organizationUrl('me'),
});
}
if (isAdmin) {
output = output.concat([
{
@@ -291,12 +307,17 @@ export default class MustacheService {
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
const filePath = await this.viewFilePath(view.path);
const isAdminPage = view.path.startsWith('/admin/');
const ownOrganization = globalParams?.organization?.owner_id === globalParams?.owner?.id;
globalParams = {
...this.defaultLayoutOptions,
...globalParams,
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
navbarMenu: this.makeNavbar(
globalParams?.currentUrl,
globalParams?.owner ? !!globalParams.owner.is_admin : false,
ownOrganization
),
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
isAdminPage,
s: {

View File

@@ -0,0 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { Organization } from './types';
export const organizationDefaultValues = (): Organization => {
return {
max_users: 2,
};
};

View File

@@ -45,8 +45,29 @@ export enum UserFlagType {
SubscriptionCancelled = 5,
ManuallyDisabled = 6,
UserDeletionInProgress = 7,
RemovedFromOrganization = 8,
}
export enum OrganizationUserInvitationStatus {
None = 0,
Sent = 1,
Accepted = 2,
Rejected = 3,
}
export const organizationUserInvitationStatusToLabel = (status: OrganizationUserInvitationStatus) => {
const s: Record<OrganizationUserInvitationStatus, string> = {
[OrganizationUserInvitationStatus.None]: 'None',
[OrganizationUserInvitationStatus.Sent]: 'Sent',
[OrganizationUserInvitationStatus.Accepted]: 'Accepted',
[OrganizationUserInvitationStatus.Rejected]: 'Rejected',
};
if (!s[status]) throw new Error(`Unknown status: ${status}`);
return s[status];
};
export function userFlagTypeToLabel(t: UserFlagType): string {
const s: Record<UserFlagType, string> = {
[UserFlagType.FailedPaymentWarning]: 'Failed Payment (Warning)',
@@ -56,6 +77,7 @@ export function userFlagTypeToLabel(t: UserFlagType): string {
[UserFlagType.SubscriptionCancelled]: 'Subscription Cancelled',
[UserFlagType.ManuallyDisabled]: 'Manually Disabled',
[UserFlagType.UserDeletionInProgress]: 'User deletion in progress',
[UserFlagType.RemovedFromOrganization]: 'Removed from organization',
};
if (!s[t]) throw new Error(`Unknown flag type: ${t}`);
@@ -277,6 +299,20 @@ export interface UserDeletion extends WithDates {
error?: string;
}
export interface Organization extends WithUuid, WithDates {
name?: string;
owner_id?: Uuid;
max_users?: number;
}
export interface OrganizationUser extends WithUuid, WithDates {
organization_id?: Uuid;
user_id?: Uuid;
invitation_email?: string;
invitation_status?: OrganizationUserInvitationStatus;
is_admin?: number;
}
export interface Email extends WithDates {
id?: number;
recipient_name?: string;
@@ -479,6 +515,24 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
organizations: {
id: { type: 'string' },
name: { type: 'string' },
owner_id: { type: 'string' },
max_users: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
organization_users: {
id: { type: 'string' },
organization_id: { type: 'string' },
user_id: { type: 'string' },
invitation_email: { type: 'string' },
invitation_status: { type: 'number' },
is_admin: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
emails: {
id: { type: 'number' },
recipient_name: { type: 'string' },

View File

@@ -2,10 +2,12 @@ import time from '@joplin/lib/time';
import { DbConnection, dropTables, migrateLatest } from '../db';
import newModelFactory from '../models/factory';
import { AccountType } from '../models/UserModel';
import { User, UserFlagType } from '../services/database/types';
import { OrganizationUserInvitationStatus, User, UserFlagType } from '../services/database/types';
import { Minute, Second } from '../utils/time';
import { Config } from '../utils/types';
const password = 'hunter1hunter2hunter3';
export interface CreateTestUsersOptions {
count?: number;
fromNum?: number;
@@ -33,8 +35,6 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
...options,
};
const password = 'hunter1hunter2hunter3';
const models = newModelFactory(db, config);
if (options.count) {
@@ -46,6 +46,7 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
email: `user${userNum}@example.com`,
password,
full_name: `User ${userNum}`,
account_type: userNum % 2 === 0 ? AccountType.Pro : AccountType.Basic,
});
}
@@ -59,6 +60,7 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
email: `user${userNum}@example.com`,
password,
full_name: `User ${userNum}`,
account_type: userNum % 2 === 0 ? AccountType.Pro : AccountType.Basic,
});
}
@@ -68,7 +70,8 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
'With Sub',
AccountType.Basic,
'usr_111',
'sub_111'
'sub_111',
1
);
await models.user().save({ id: user.id, password });
}
@@ -79,7 +82,8 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
'Failed Payment',
AccountType.Basic,
'usr_222',
'sub_222'
'sub_222',
1
);
await models.user().save({ id: user.id, password });
await models.subscription().handlePayment(subscription.stripe_subscription_id, false);
@@ -111,3 +115,29 @@ export async function createUserDeletions(db: DbConnection, config: Config) {
await models.userDeletion().add(users[i].id, Date.now() + 60 * Second + (i * 10 * Minute));
}
}
export async function createOrganizations(db: DbConnection, config: Config) {
const models = newModelFactory(db, config);
for (let orgNum = 1; orgNum <= 3; orgNum++) {
const owner = await models.user().save({
email: `orgowner${orgNum}@example.com`,
password,
full_name: `Org Owner ${orgNum}`,
account_type: AccountType.Pro,
});
const org = await models.organizations().save({
max_users: 10,
owner_id: owner.id,
name: `Org ${orgNum}`,
});
for (let userNum = 1; userNum <= 5; userNum++) {
const userEmail = `orguser${orgNum}-${userNum}@example.com`;
const orgUser = await models.organizations().inviteUser(org.id, userEmail);
await models.organizations().respondInvitation(orgUser.id, OrganizationUserInvitationStatus.Accepted);
}
}
}

View File

@@ -23,48 +23,51 @@ const config = {
'filename': './db',
'extends': {
'main.api_clients': 'WithDates, WithUuid',
'main.backup_items': 'WithCreatedDate',
'main.changes': 'WithDates, WithUuid',
'main.emails': 'WithDates',
'main.events': 'WithUuid',
'main.items': 'WithDates, WithUuid',
'main.notifications': 'WithDates, WithUuid',
'main.organization_users': 'WithUuid, WithDates',
'main.organizations': 'WithUuid, WithDates',
'main.sessions': 'WithDates, WithUuid',
'main.share_users': 'WithDates, WithUuid',
'main.shares': 'WithDates, WithUuid',
'main.tokens': 'WithDates',
'main.user_deletions': 'WithDates',
'main.user_flags': 'WithDates',
'main.user_items': 'WithDates',
'main.users': 'WithDates, WithUuid',
'main.events': 'WithUuid',
'main.user_deletions': 'WithDates',
'main.backup_items': 'WithCreatedDate',
},
};
const propertyTypes: Record<string, string> = {
'*.item_type': 'ItemType',
'backup_items.content': 'Buffer',
'changes.type': 'ChangeType',
'emails.sender_id': 'EmailSender',
'emails.sent_time': 'number',
'events.created_time': 'number',
'events.type': 'EventType',
'items.content': 'Buffer',
'items.jop_updated_time': 'number',
'notifications.level': 'NotificationLevel',
'organization_users.invitation_status': 'OrganizationUserInvitationStatus',
'share_users.status': 'ShareUserStatus',
'shares.type': 'ShareType',
'subscriptions.last_payment_failed_time': 'number',
'subscriptions.last_payment_time': 'number',
'user_deletions.end_time': 'number',
'user_deletions.scheduled_time': 'number',
'user_deletions.start_time': 'number',
'user_flags.type': 'UserFlagType',
'users.can_share_folder': 'number | null',
'users.can_share_note': 'number | null',
'users.disabled_time': 'number',
'users.max_item_size': 'number | null',
'users.max_total_item_size': 'number | null',
'users.total_item_size': 'number',
'events.created_time': 'number',
'events.type': 'EventType',
'user_deletions.start_time': 'number',
'user_deletions.end_time': 'number',
'user_deletions.scheduled_time': 'number',
'users.disabled_time': 'number',
'backup_items.content': 'Buffer',
};
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {
@@ -112,6 +115,7 @@ function createTypeString(table: any) {
const header = ['export interface'];
header.push(table.interfaceName);
if (table.extends) header.push(`extends ${table.extends}`);
return `${header.join(' ')} {\n${colStrings.join('\n')}\n}`;

View File

@@ -6,6 +6,7 @@ import { AppContext, HttpMethod, RouteType } from './types';
import { URL } from 'url';
import { csrfCheck } from './csrf';
import { contextSessionId } from './requestUtils';
import { shortToLong } from './uuid';
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
@@ -79,6 +80,11 @@ export function redirect(ctx: AppContext, url: string): Response {
return new Response(ResponseType.KoaResponse, ctx.response);
}
export function internalRedirect(path: SubPath, ctx: AppContext, router: Router, urlSchema: string, ...args: any[]) {
const endPoint = router.findEndPoint(HttpMethod.GET, urlSchema);
return endPoint.handler(path, ctx, ...args);
}
export function filePathInfo(path: string): PathInfo {
return {
basename: removeTrailingColon(basename(path)),
@@ -189,6 +195,24 @@ function disabledAccountCheck(route: MatchedRoute, user: User) {
if (route.subPath.schema.startsWith('api/')) throw new ErrorForbidden(`This account is disabled. Please login to ${config().baseUrl} for more information.`);
}
const needsConvertedId = (path: SubPath): boolean => {
const { schema } = path;
if (schema.startsWith('admin/organizations/:id')) return true;
if (schema.startsWith('admin/organization_users/:id')) return true;
return false;
};
const convertPathId = (path: SubPath): SubPath => {
if (needsConvertedId(path)) {
return {
...path,
id: shortToLong(path.id),
};
}
return path;
};
export async function execRequest(routes: Routers, ctx: AppContext) {
const match = findMatchingRoute(ctx.path, routes);
if (!match) throw new ErrorNotFound();
@@ -215,7 +239,8 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
await csrfCheck(ctx, isPublicRoute);
disabledAccountCheck(match, ctx.joplin.owner);
return endPoint.handler(match.subPath, ctx);
const convertedPath = convertPathId(match.subPath);
return endPoint.handler(convertedPath, ctx);
}
// In a path such as "/api/files/SOME_ID/content" we want to find:

View File

@@ -4,7 +4,7 @@ import loadStorageDriver from '../models/items/storage/loadStorageDriver';
import parseStorageConnectionString from '../models/items/storage/parseStorageConnectionString';
import { Context } from '../models/items/storage/StorageDriverBase';
import { StorageDriverConfig, StorageDriverType } from './types';
import uuidgen from './uuidgen';
import { uuidgen } from './uuid';
export default async function(connection: string | StorageDriverConfig, db: DbConnection, models: Models): Promise<string> {
const storageConfig = typeof connection === 'string' ? parseStorageConnectionString(connection) : connection;

View File

@@ -6,6 +6,7 @@ import { Models } from '../models/factory';
import { AccountType } from '../models/UserModel';
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
import { ErrorWithCode } from './errors';
import { validateOrganizationMaxUsers } from './validation';
const stripeLib = require('stripe');
export interface SubscriptionInfo {
@@ -149,3 +150,17 @@ export async function updateCustomerEmail(models: Models, userId: Uuid, newEmail
email: newEmail,
});
}
export const updateOrganizationCapacity = async (models: Models, orgId: Uuid, newCapacity: number) => {
validateOrganizationMaxUsers(newCapacity);
const org = await models.organizations().load(orgId);
const subInfo = await subscriptionInfoByUserId(models, org.owner_id);
const stripe = initStripe();
const currentItem = subInfo.stripeSub.items.data[0];
await stripe.subscriptionItems.update(currentItem.id, {
quantity: newCapacity,
});
};

View File

@@ -20,7 +20,7 @@ import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/d
import { ModelType } from '@joplin/lib/BaseModel';
import { initializeJoplinUtils } from '../joplinUtils';
import MustacheService from '../../services/MustacheService';
import uuidgen from '../uuidgen';
import { uuidgen } from '../uuid';
import { createCsrfToken } from '../csrf';
import { cookieSet } from '../cookies';
import { parseEnv } from '../../env';
@@ -75,9 +75,13 @@ export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOpt
const tempDir = `${packageRootDir}/temp/test-${unitName}`;
await fs.mkdirp(tempDir);
// Uncomment the code below to run the test units with Postgres. Run this:
// To run the tests with Postgres, first run this:
//
// sudo docker compose -f docker-compose.db-dev.yml up
// sudo docker compose -f docker-compose.db-dev.yml up
//
// Then this:
//
// JOPLIN_TESTS_SERVER_DB=pg yarn test
if (process.env.JOPLIN_TESTS_SERVER_DB === 'pg') {
await initConfig(Env.Dev, parseEnv({

View File

@@ -1,7 +1,7 @@
import { LoggerWrapper } from '@joplin/lib/Logger';
import { StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
import * as Koa from 'koa';
import { User, Uuid } from '../services/database/types';
import { Organization, User, Uuid } from '../services/database/types';
import { Models } from '../models/factory';
import { Account } from '../models/UserModel';
import { Services } from '../services/types';
@@ -29,6 +29,7 @@ interface AppContextJoplin {
appLogger(): LoggerWrapper;
notifications: NotificationView[];
owner: User;
organization: Organization;
account: Account;
routes: Routers;
services: Services;
@@ -163,6 +164,7 @@ export interface Config extends EnvVariables {
storageDriverFallback: StorageDriverConfig;
itemSizeHardLimit: number;
maxTimeDrift: number;
organizationsEnabled: boolean;
}
export enum HttpMethod {

View File

@@ -1,6 +1,7 @@
import { URL } from 'url';
import config from '../config';
import { Uuid } from '../services/database/types';
import { longToShort } from './uuid';
export function setQueryParameters(url: string, query: any): string {
if (!query) return url;
@@ -62,6 +63,18 @@ export function loginUrl(): string {
return `${config().baseUrl}/login`;
}
export function organizationUrl(id: string): string {
return `${config().baseUrl}/organizations/${longToShort(id)}`;
}
export function organizationUsersUrl(id: string): string {
return `${config().baseUrl}/organizations/${longToShort(id)}/users`;
}
export function organizationUserUrl(orgUserId: string): string {
return `${config().baseUrl}/organization_users/${longToShort(orgUserId)}`;
}
export function adminUserDeletionsUrl(): string {
return `${config().adminBaseUrl}/user_deletions`;
}
@@ -70,6 +83,18 @@ export function userUrl(userId: Uuid): string {
return `${config().baseUrl}/users/${userId}`;
}
export function organizationInvitationConfirmUrl(orgUserId: string, token: string): string {
return `${config().baseUrl}/organization_users/${orgUserId}/confirm?token=${token}`;
}
export function adminOrganizationUrl(id: string): string {
return `${config().baseUrl}/admin/organizations/${longToShort(id)}`;
}
export function adminOrganizationsUrl(): string {
return `${config().baseUrl}/admin/organizations`;
}
export function adminDashboardUrl(): string {
return `${config().adminBaseUrl}/dashboard`;
}

View File

@@ -0,0 +1,36 @@
import shortUuid = require('short-uuid');
const generate = require('nanoid/generate');
const charSet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let shortUuidTranslator_: shortUuid.Translator = null;
export const shortUuidTranslator = () => {
if (!shortUuidTranslator_) shortUuidTranslator_ = shortUuid(charSet);
return shortUuidTranslator_;
};
// https://zelark.github.io/nano-id-cc/
// https://security.stackexchange.com/a/41749/1873
// > On the other hand, 128 bits (between 21 and 22 characters
// > alphanumeric) is beyond the reach of brute-force attacks pretty much
// > indefinitely
export const uuidgen = (length: number = 22): string => {
return generate(charSet, length);
};
export const isReservedId = (s: string): boolean => {
return ['me', 'new'].includes(s);
};
export const shortToLong = (shortId: string): string => {
return shortId;
// if (isReservedId(shortId)) return shortId;
// return shortUuidTranslator().toUUID(shortId);
};
export const longToShort = (longId: string): string => {
return longId;
// if (isReservedId(longId)) return longId;
// return shortUuidTranslator().fromUUID(longId);
};

View File

@@ -1,10 +0,0 @@
const generate = require('nanoid/generate');
// https://zelark.github.io/nano-id-cc/
// https://security.stackexchange.com/a/41749/1873
// > On the other hand, 128 bits (between 21 and 22 characters
// > alphanumeric) is beyond the reach of brute-force attacks pretty much
// > indefinitely
export default function uuidgen(length: number = 22): string {
return generate('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', length);
}

View File

@@ -0,0 +1,16 @@
/* eslint-disable import/prefer-default-export */
import { ErrorUnprocessableEntity } from './errors';
export const organizationMinUsers = 2;
export const organizationMaxUsers = 100;
export const validateEmail = (email: string) => {
const s = email.split('@');
if (s.length !== 2) throw new ErrorUnprocessableEntity(`Invalid email: ${email}`);
if (!s[0].length || !s[1].length) throw new ErrorUnprocessableEntity(`Invalid email: ${email}`);
};
export const validateOrganizationMaxUsers = (maxUsers: number) => {
if (isNaN(maxUsers) || maxUsers < organizationMinUsers || maxUsers > organizationMaxUsers) throw new ErrorUnprocessableEntity(`Organisation must have at least ${organizationMinUsers} users`);
};

View File

@@ -0,0 +1,37 @@
<h1 class="title">Organization</h1>
<form id="organization_form" action="{{{postUrl}}}" method="POST" class="block">
<div class="block">
{{> errorBanner}}
{{{csrfTag}}}
<input type="hidden" name="id" value="{{fields.id}}"/>
<input type="hidden" name="is_new" value="{{isNew}}"/>
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" value="{{fields.name}}"/>
</div>
</div>
<div class="field">
<label class="label">Owner email</label>
<div class="control">
<input class="input" type="text" name="owner_email" value="{{fields.owner_email}}"/>
</div>
<p class="help">{{s.ownerEmailMustExist}}</p>
</div>
<div class="field">
<label class="label">Max users</label>
<div class="control">
<input class="input" type="number" name="max_users" value="{{fields.max_users}}"/>
</div>
</div>
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
</div>
</div>
</form>

View File

@@ -0,0 +1,11 @@
<div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/organizations/new">Add organization</a>
</div>
<form method='POST' action="{{postUrl}}">
{{{csrfTag}}}
{{#organizationTable}}
{{>table}}
{{/organizationTable}}
</form>

View File

@@ -0,0 +1,22 @@
import markdownUtils from '@joplin/lib/markdownUtils';
import config from '../../config';
import { EmailSubjectBody } from '../../models/EmailModel';
interface TemplateView {
organizationName: string;
url: string;
}
export default function(view: TemplateView): EmailSubjectBody {
return {
subject: `You are invited to join the organisation "${view.organizationName}" on ${config().appName}`,
body: `
You have been invited to join the organisation "${view.organizationName}" on ${config().appName}.
To proceed, please click the link below:
[Join the organization](${markdownUtils.escapeLinkUrl(view.url)})
`
.trim(),
};
}

View File

@@ -1,17 +1,17 @@
{{#showBetaMessage}}
<div class="notification is-warning">
<p class="block">This is a free beta account that will expire in <strong>{{betaExpiredDays}} day(s).</strong></p>
<p class="block">To continue using it after this date, please start the subscription by clicking on the button below. From the next screen, select either monthly or yearly payments and click "Buy now".</p>
<p class="block">Note that remaining days on the beta trial period will be transferred to the new subscription, so you will not lose any trial day. It means you do not need to wait till the last day to start the subscription.</p>
<div class="notification is-warning content">
<p>This is a free beta account that will expire in <strong>{{betaExpiredDays}} day(s).</strong></p>
<p>To continue using it after this date, please start the subscription by clicking on the button below. From the next screen, select either monthly or yearly payments and click "Buy now".</p>
<p>Note that remaining days on the beta trial period will be transferred to the new subscription, so you will not lose any trial day. It means you do not need to wait till the last day to start the subscription.</p>
<a href="{{{betaStartSubUrl}}}" class="button is-link">Start Subscription</a>
</div>
{{/showBetaMessage}}
<h2 class="title">Welcome to {{global.appName}}</h2>
<div class="block readable-block">
<p class="block">To start using {{global.appName}}, make sure to <a href="{{{global.joplinAppBaseUrl}}}/download">download one of the Joplin applications</a>, either for desktop or for your mobile phone.</p>
<p class="block">Once the app is installed, open the <strong>Configuration</strong> screen, then the <strong>Synchronisation</strong> section. {{{setupMessageHtml}}}<p>
<p class="block">Once it is setup {{global.appName}} allows you to synchronise your devices, to publish notes, or to collaborate on notebooks with other {{global.appName}} users.</p>
<div class="block readable-block content">
<p>To start using {{global.appName}}, make sure to <a href="{{{global.joplinAppBaseUrl}}}/download">download one of the Joplin applications</a>, either for desktop or for your mobile phone.</p>
<p>Once the app is installed, open the <strong>Configuration</strong> screen, then the <strong>Synchronisation</strong> section. {{{setupMessageHtml}}}<p>
<p>Once it is setup {{global.appName}} allows you to synchronise your devices, to publish notes, or to collaborate on notebooks with other {{global.appName}} users.</p>
</div>
<h2 class="title">Your account</h2>
@@ -33,6 +33,12 @@
</tbody>
</table>
{{#organization}}
<h2 class="title">Your organisation</h2>
<p>You are a member of the organization <strong>{{organization.name}}</strong></p>
{{/organization}}
{{#showUpgradeProButton}}
<p class="block">
<a href="{{{global.baseUrl}}}/upgrade" class="upgrade-button">Upgrade to a Pro account</a> to collaborate on notebooks with other people, to increase the max note size, or the max total size.

View File

@@ -0,0 +1,30 @@
<h1 class="title">Organization</h1>
<form id="organization_form" action="{{{postUrl}}}" method="POST" class="block">
<div class="block">
{{> errorBanner}}
{{{csrfTag}}}
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" value="{{fields.name}}"/>
</div>
</div>
<div class="field">
<label class="label">User capacity</label>
<div class="control">
<input type="number" name="max_users" value="{{fields.max_users}}"/>
{{#global.isJoplinCloud}}
<p class="help">When increasing capacity, billing will be updated accordingly.</p>
{{/global.isJoplinCloud}}
</div>
</div>
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="Update" />
</div>
</div>
</form>

View File

@@ -0,0 +1,13 @@
<form method="post" action="{{{postUrl}}}">
{{{csrfTag}}}
<input type="hidden" name="organization_user_id" value="{{orgUser.id}}"/>
<div class="block">
<strong>Full name:</strong> {{user.full_name}}<br/>
<strong>Email:</strong> <a href="mailto:{{user.email}}">{{user.email}}</a>
</div>
<div class="control block">
<input type="submit" name="remove_user_button" class="button is-danger" value="Remove user" />
</div>
</form>

View File

@@ -0,0 +1,25 @@
<form method='POST' action="{{postUrl}}">
{{> errorBanner}}
{{{csrfTag}}}
<div class="field">
<label class="label">Invite users:</label>
<div class="control">
<input {{^remainingInvitationCount}}disabled{{/remainingInvitationCount}} class="input" type="text" name="emails" value="{{fields.emails}}"/>
</div>
{{#remainingInvitationCount}}
<p class="help">To invite new users, enter their emails separated by a comma. You may invite {{remainingInvitationCount}} more user(s).</p>
{{/remainingInvitationCount}}
{{^remainingInvitationCount}}
<p class="help">No more users can be invited to join this organisation. You may <a href="{{{updateCapacityUrl}}}">increase the organisation capacity</a>.</p>
{{/remainingInvitationCount}}
</div>
<div class="control block">
<input type="submit" name="invite_users_button" class="button is-primary" value="Invite users" />
</div>
{{#organizationUserTable}}
{{>table}}
{{/organizationUserTable}}
</form>

View File

@@ -30,6 +30,20 @@
"period": "yearly",
"amount": "57.48",
"currency": "EUR"
},
{
"accountType": 3,
"id": "price_1KP3puLx4fybOTqJQ72hmD9B",
"period": "monthly",
"amount": "5.99",
"currency": "EUR"
},
{
"accountType": 3,
"id": "price_1KP3puLx4fybOTqJQ72hmD9B",
"period": "yearly",
"amount": "57.48",
"currency": "EUR"
}
]
},

View File

@@ -3397,6 +3397,7 @@ __metadata:
query-string: ^6.8.3
rate-limiter-flexible: ^2.2.4
raw-body: ^2.4.1
short-uuid: ^4.2.0
source-map-support: ^0.5.13
sqlite3: ^4.1.0
stripe: ^8.150.0
@@ -6654,6 +6655,13 @@ __metadata:
languageName: node
linkType: hard
"any-base@npm:^1.1.0":
version: 1.1.0
resolution: "any-base@npm:1.1.0"
checksum: c1fd040de52e710e2de7d9ae4df52bac589f35622adb24686c98ce21c7b824859a8db9614bc119ed8614f42fd08918b2612e6a6c385480462b3100a1af59289d
languageName: node
linkType: hard
"any-observable@npm:^0.3.0":
version: 0.3.0
resolution: "any-observable@npm:0.3.0"
@@ -26953,6 +26961,16 @@ __metadata:
languageName: node
linkType: hard
"short-uuid@npm:^4.2.0":
version: 4.2.0
resolution: "short-uuid@npm:4.2.0"
dependencies:
any-base: ^1.1.0
uuid: ^8.3.2
checksum: 09013559393bc26d1462ae27c84b4eb7e6e4052e9fea1704f21370e226864f9dfcd1b9465eefa980da84b2537b70cabd98718193934dcc2a0fc06e7b0d1f4b9e
languageName: node
linkType: hard
"should-equal@npm:^2.0.0":
version: 2.0.0
resolution: "should-equal@npm:2.0.0"