You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
35 Commits
android-v3
...
server_org
Author | SHA1 | Date | |
---|---|---|---|
|
d8e24cff27 | ||
|
54c0df57b6 | ||
|
f27af09bc4 | ||
|
9cae2a5fd0 | ||
|
cb6c402d59 | ||
|
5b5adccab3 | ||
|
8b6c4e2850 | ||
|
5a08654f91 | ||
|
61928bee98 | ||
|
a9f2da8bba | ||
|
6ba1e0cb91 | ||
|
3d84efb9ed | ||
|
81de2eb26a | ||
|
6082f8f869 | ||
|
82101c9a0e | ||
|
6a4d0b9e79 | ||
|
a5c78ebd36 | ||
|
d72c3b3b33 | ||
|
1a66488978 | ||
|
24eda30d0a | ||
|
c0b93bbe32 | ||
|
369165004a | ||
|
595c14caaa | ||
|
bcf6f95a9f | ||
|
4bca995c23 | ||
|
0531165364 | ||
|
8bf6730a24 | ||
|
ec800d1da3 | ||
|
d586502376 | ||
|
5de29dd4e9 | ||
|
8aa417c0de | ||
|
2ac07566f8 | ||
|
4ff5d16658 | ||
|
6af9f23cb5 | ||
|
4338c2041f |
@@ -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"
|
||||
|
@@ -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.
@@ -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,
|
||||
};
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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) {
|
||||
|
@@ -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');
|
||||
}
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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> {
|
||||
|
||||
|
@@ -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 {
|
||||
|
198
packages/server/src/models/OrganizationModel.test.ts
Normal file
198
packages/server/src/models/OrganizationModel.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
175
packages/server/src/models/OrganizationModel.ts
Normal file
175
packages/server/src/models/OrganizationModel.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
61
packages/server/src/models/OrganizationUserModel.ts
Normal file
61
packages/server/src/models/OrganizationUserModel.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -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';
|
||||
|
||||
|
@@ -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({
|
||||
|
@@ -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');
|
||||
}
|
||||
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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_);
|
||||
}
|
||||
|
182
packages/server/src/routes/admin/organizations.ts
Normal file
182
packages/server/src/routes/admin/organizations.ts
Normal 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;
|
@@ -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> {
|
||||
|
@@ -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);
|
||||
|
@@ -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));
|
||||
// });
|
||||
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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());
|
||||
}
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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'];
|
||||
|
@@ -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);
|
||||
}
|
||||
|
104
packages/server/src/routes/index/organization_users.ts
Normal file
104
packages/server/src/routes/index/organization_users.ts
Normal 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;
|
214
packages/server/src/routes/index/organizations.ts
Normal file
214
packages/server/src/routes/index/organizations.ts
Normal 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;
|
@@ -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() {
|
||||
|
@@ -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() {
|
||||
|
@@ -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 {
|
||||
|
@@ -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);
|
||||
|
@@ -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;
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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,
|
||||
|
@@ -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: {
|
||||
|
9
packages/server/src/services/database/defaultValues.ts
Normal file
9
packages/server/src/services/database/defaultValues.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { Organization } from './types';
|
||||
|
||||
export const organizationDefaultValues = (): Organization => {
|
||||
return {
|
||||
max_users: 2,
|
||||
};
|
||||
};
|
@@ -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' },
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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}`;
|
||||
|
@@ -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:
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
});
|
||||
};
|
||||
|
@@ -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({
|
||||
|
@@ -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 {
|
||||
|
@@ -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`;
|
||||
}
|
||||
|
36
packages/server/src/utils/uuid.ts
Normal file
36
packages/server/src/utils/uuid.ts
Normal 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);
|
||||
};
|
@@ -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);
|
||||
}
|
16
packages/server/src/utils/validation.ts
Normal file
16
packages/server/src/utils/validation.ts
Normal 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`);
|
||||
};
|
37
packages/server/src/views/admin/organization.mustache
Normal file
37
packages/server/src/views/admin/organization.mustache
Normal 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>
|
11
packages/server/src/views/admin/organizations.mustache
Normal file
11
packages/server/src/views/admin/organizations.mustache
Normal 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>
|
22
packages/server/src/views/emails/orgInviteUserTemplate.ts
Normal file
22
packages/server/src/views/emails/orgInviteUserTemplate.ts
Normal 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(),
|
||||
};
|
||||
}
|
@@ -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.
|
||||
|
30
packages/server/src/views/index/organizations/index.mustache
Normal file
30
packages/server/src/views/index/organizations/index.mustache
Normal 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>
|
13
packages/server/src/views/index/organizations/user.mustache
Normal file
13
packages/server/src/views/index/organizations/user.mustache
Normal 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>
|
25
packages/server/src/views/index/organizations/users.mustache
Normal file
25
packages/server/src/views/index/organizations/users.mustache
Normal 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>
|
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
18
yarn.lock
18
yarn.lock
@@ -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"
|
||||
|
Reference in New Issue
Block a user