Early terms management UI

This commit is contained in:
Travis Ralston 2019-06-30 23:05:33 -06:00
parent d9637b1d3d
commit a11e57db31
16 changed files with 332 additions and 10 deletions

View File

@ -0,0 +1,42 @@
import { GET, Path, PathParam, POST, Security } from "typescript-rest";
import TermsController, { ITerms } from "../controllers/TermsController";
import { AutoWired, Inject } from "typescript-ioc/es6";
import { ROLE_MSC_ADMIN, ROLE_MSC_USER } from "../security/MSCSecurity";
interface CreatePolicyObject {
name: string;
text?: string;
url: string;
}
/**
* Administrative API for configuring terms of service.
*/
@Path("/api/v1/dimension/admin/terms")
@AutoWired
export class AdminTermsService {
@Inject
private termsController: TermsController;
@GET
@Path("all")
@Security([ROLE_MSC_USER, ROLE_MSC_ADMIN])
public async getPolicies(): Promise<ITerms[]> {
return this.termsController.getPoliciesForAdmin();
}
@GET
@Path(":shortcode/:version")
@Security([ROLE_MSC_USER, ROLE_MSC_ADMIN])
public async getPolicy(@PathParam("shortcode") shortcode: string, @PathParam("version") version: string): Promise<ITerms> {
return this.termsController.getPolicyForAdmin(shortcode, version);
}
@POST
@Path(":shortcode/draft")
@Security([ROLE_MSC_USER, ROLE_MSC_ADMIN])
public async createDraftPolicy(@PathParam("shortcode") shortcode: string, request: CreatePolicyObject): Promise<ITerms> {
return this.termsController.createDraftPolicy(request.name, shortcode, request.text, request.url);
}
}

View File

@ -1,5 +1,7 @@
import { AutoWired } from "typescript-ioc/es6"; import { AutoWired } from "typescript-ioc/es6";
import { IMSCUser } from "../security/MSCSecurity"; import { IMSCUser } from "../security/MSCSecurity";
import TermsRecord from "../../db/models/TermsRecord";
import TermsTextRecord from "../../db/models/TermsTextRecord";
export interface ILanguagePolicy { export interface ILanguagePolicy {
name: string; name: string;
@ -17,6 +19,20 @@ export interface ITermsNotSignedResponse {
policies: { [policyName: string]: IPolicy }; policies: { [policyName: string]: IPolicy };
} }
export interface ITerms {
shortcode: string;
version: string;
languages: {
[lang: string]: {
name: string;
url: string;
text?: string;
};
};
}
export const VERSION_DRAFT = "draft";
/** /**
* API controller for terms of service management * API controller for terms of service management
*/ */
@ -30,6 +46,39 @@ export default class TermsController {
} }
public async getMissingTermsForUser(_user: IMSCUser): Promise<ITermsNotSignedResponse> { public async getMissingTermsForUser(_user: IMSCUser): Promise<ITermsNotSignedResponse> {
// TODO: Abuse a cache for non-draft policies
return {policies: {}}; return {policies: {}};
} }
public async getPoliciesForAdmin(): Promise<ITerms[]> {
const terms = await TermsRecord.findAll({include: [TermsTextRecord]});
return terms.map(this.mapPolicy.bind(this, false));
}
public async getPolicyForAdmin(shortcode: string, version: string): Promise<ITerms> {
const terms = await TermsRecord.findOne({where: {shortcode, version}, include: [TermsTextRecord]});
return this.mapPolicy(true, terms);
}
public async createDraftPolicy(name: string, shortcode: string, text: string, url: string): Promise<ITerms> {
const terms = await TermsRecord.create({shortcode, version: VERSION_DRAFT});
const termsText = await TermsTextRecord.create({termsId: terms.id, language: "en", name, text, url});
terms.texts = [termsText];
return this.mapPolicy(true, terms);
}
private mapPolicy(withText: boolean, policy: TermsRecord): ITerms {
const languages = {};
policy.texts.forEach(pt => languages[pt.language] = {
name: pt.name,
url: pt.url,
text: withText ? pt.text : null,
});
return {
shortcode: policy.shortcode,
version: policy.version,
languages: languages,
};
}
} }

View File

@ -4,6 +4,7 @@ import { ApiError } from "../ApiError";
import { LogService } from "matrix-js-snippets"; import { LogService } from "matrix-js-snippets";
import AccountController from "../controllers/AccountController"; import AccountController from "../controllers/AccountController";
import TermsController from "../controllers/TermsController"; import TermsController from "../controllers/TermsController";
import config from "../../config";
export interface IMSCUser { export interface IMSCUser {
userId: string; userId: string;
@ -11,6 +12,7 @@ export interface IMSCUser {
} }
export const ROLE_MSC_USER = "ROLE_MSC_USER"; export const ROLE_MSC_USER = "ROLE_MSC_USER";
export const ROLE_MSC_ADMIN = "ROLE_MSC_ADMIN";
const TERMS_IGNORED_ROUTES = [ const TERMS_IGNORED_ROUTES = [
{method: "GET", path: "/_matrix/integrations/v1/terms"}, {method: "GET", path: "/_matrix/integrations/v1/terms"},
@ -25,7 +27,13 @@ export default class MSCSecurity implements ServiceAuthenticator {
private termsController = new TermsController(); private termsController = new TermsController();
public getRoles(req: Request): string[] { public getRoles(req: Request): string[] {
if (req.user) return [ROLE_MSC_USER]; if (req.user) {
const roles = [ROLE_MSC_USER];
if (config.admins.includes(req.user.userId)) {
roles.push(ROLE_MSC_ADMIN);
}
return roles;
}
return []; return [];
} }

View File

@ -26,6 +26,8 @@ import WebhookBridgeRecord from "./models/WebhookBridgeRecord";
import GitterBridgeRecord from "./models/GitterBridgeRecord"; import GitterBridgeRecord from "./models/GitterBridgeRecord";
import CustomSimpleBotRecord from "./models/CustomSimpleBotRecord"; import CustomSimpleBotRecord from "./models/CustomSimpleBotRecord";
import SlackBridgeRecord from "./models/SlackBridgeRecord"; import SlackBridgeRecord from "./models/SlackBridgeRecord";
import TermsRecord from "./models/TermsRecord";
import TermsTextRecord from "./models/TermsTextRecord";
class _DimensionStore { class _DimensionStore {
private sequelize: Sequelize; private sequelize: Sequelize;
@ -63,6 +65,8 @@ class _DimensionStore {
GitterBridgeRecord, GitterBridgeRecord,
CustomSimpleBotRecord, CustomSimpleBotRecord,
SlackBridgeRecord, SlackBridgeRecord,
TermsRecord,
TermsTextRecord,
]); ]);
} }

View File

@ -0,0 +1,31 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_terms", {
// Ideally we'd use a composite primary key here, but that's not really possible with our libraries.
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"shortcode": {type: DataType.STRING, allowNull: false},
"version": {type: DataType.STRING, allowNull: false},
}))
.then(() => queryInterface.createTable("dimension_terms_text", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"termsId": {
type: DataType.INTEGER, allowNull: true,
references: {model: "dimension_terms", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"language": {type: DataType.STRING, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
"text": {type: DataType.STRING, allowNull: true},
"url": {type: DataType.STRING, allowNull: false},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_terms"))
.then(() => queryInterface.dropTable("dimension_terms_text"));
}
}

View File

@ -0,0 +1,23 @@
import { AutoIncrement, Column, HasMany, Model, PrimaryKey, Table } from "sequelize-typescript";
import TermsTextRecord from "./TermsTextRecord";
@Table({
tableName: "dimension_terms",
underscored: false,
timestamps: false,
})
export default class TermsRecord extends Model<TermsRecord> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
shortcode: string;
@Column
version: string;
@HasMany(() => TermsTextRecord)
texts: TermsTextRecord[];
}

View File

@ -0,0 +1,44 @@
import {
AllowNull,
AutoIncrement,
BelongsTo,
Column,
ForeignKey,
Model,
PrimaryKey,
Table
} from "sequelize-typescript";
import TermsRecord from "./TermsRecord";
@Table({
tableName: "dimension_terms_text",
underscored: false,
timestamps: false,
})
export default class TermsTextRecord extends Model<TermsTextRecord> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Column
@ForeignKey(() => TermsRecord)
termsId?: number;
@BelongsTo(() => TermsRecord)
terms: TermsRecord;
@Column
language: string;
@Column
url: string;
@Column
name: string;
@AllowNull
@Column
text?: string;
}

View File

@ -5,6 +5,7 @@
<li (click)="goto('custom-bots')" [ngClass]="[isActive('custom-bots') ? 'active' : '']">Custom Bots</li> <li (click)="goto('custom-bots')" [ngClass]="[isActive('custom-bots') ? 'active' : '']">Custom Bots</li>
<li (click)="goto('bridges')" [ngClass]="[isActive('bridges') ? 'active' : '']">Bridges</li> <li (click)="goto('bridges')" [ngClass]="[isActive('bridges') ? 'active' : '']">Bridges</li>
<li (click)="goto('stickerpacks')" [ngClass]="[isActive('stickerpacks') ? 'active' : '']">Sticker Packs</li> <li (click)="goto('stickerpacks')" [ngClass]="[isActive('stickerpacks') ? 'active' : '']">Sticker Packs</li>
<li (click)="goto('terms')" [ngClass]="[isActive('terms') ? 'active' : '']">Terms of Service</li>
</ul> </ul>
<span class="version">{{ version }}</span> <span class="version">{{ version }}</span>

View File

@ -0,0 +1,40 @@
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!isLoading">
<my-ibox boxTitle="Terms of Service">
<div class="my-ibox-content">
<p>
Before users can use Dimension they must agree to the terms of service for using your
instance. If you're using any matrix.org bridges, users will be required to accept
the terms of service for your upstream integration managers (scalar.vector.im usually)
in addition to the terms you add here.
</p>
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Policy Name</th>
<th>Published Version</th>
<th class="text-center" style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!policies || policies.length === 0">
<td colspan="3"><i>No policies written.</i></td>
</tr>
<tr *ngFor="let policy of policies trackById">
<td>{{ policy.languages['en'].name }}</td>
<td>{{ policy.version }}</td>
<td class="text-center">
<span class="previewButton" title="edit draft" *ngIf="policy.version == 'draft'">
<i class="fa fa-pencil-alt"></i>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</my-ibox>
</div>

View File

View File

@ -0,0 +1,29 @@
import { Component, OnInit } from "@angular/core";
import { ToasterService } from "angular2-toaster";
import { FE_TermsEditable } from "../../shared/models/terms";
import { AdminTermsApiService } from "../../shared/services/admin/admin-terms-api.service";
@Component({
templateUrl: "./terms.component.html",
styleUrls: ["./terms.component.scss"],
})
export class AdminTermsComponent implements OnInit {
public isLoading = true;
public policies: FE_TermsEditable[];
constructor(private adminTerms: AdminTermsApiService,
private toaster: ToasterService) {
}
public ngOnInit() {
this.adminTerms.getAllPolicies().then(policies => {
this.policies = policies;
this.isLoading = false;
}).catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to load policies");
});
}
}

View File

@ -112,6 +112,8 @@ import { AdminSlackApiService } from "./shared/services/admin/admin-slack-api.se
import { AdminLogoutConfirmationDialogComponent } from "./admin/home/logout-confirmation/logout-confirmation.component"; import { AdminLogoutConfirmationDialogComponent } from "./admin/home/logout-confirmation/logout-confirmation.component";
import { ReauthExampleWidgetWrapperComponent } from "./widget-wrappers/reauth-example/reauth-example.component"; import { ReauthExampleWidgetWrapperComponent } from "./widget-wrappers/reauth-example/reauth-example.component";
import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-test/manager-test.component"; import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-test/manager-test.component";
import { AdminTermsApiService } from "./shared/services/admin/admin-terms-api.service";
import { AdminTermsComponent } from "./admin/terms/terms.component";
@NgModule({ @NgModule({
imports: [ imports: [
@ -204,6 +206,7 @@ import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-tes
AdminLogoutConfirmationDialogComponent, AdminLogoutConfirmationDialogComponent,
ReauthExampleWidgetWrapperComponent, ReauthExampleWidgetWrapperComponent,
ManagerTestWidgetWrapperComponent, ManagerTestWidgetWrapperComponent,
AdminTermsComponent,
// Vendor // Vendor
], ],
@ -233,6 +236,7 @@ import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-tes
SlackApiService, SlackApiService,
AdminSlackApiService, AdminSlackApiService,
ToasterService, ToasterService,
AdminTermsApiService,
{provide: Window, useValue: window}, {provide: Window, useValue: window},
// Vendor // Vendor

View File

@ -44,6 +44,7 @@ import { AdminSlackBridgeComponent } from "./admin/bridges/slack/slack.component
import { SlackBridgeConfigComponent } from "./configs/bridge/slack/slack.bridge.component"; import { SlackBridgeConfigComponent } from "./configs/bridge/slack/slack.bridge.component";
import { ReauthExampleWidgetWrapperComponent } from "./widget-wrappers/reauth-example/reauth-example.component"; import { ReauthExampleWidgetWrapperComponent } from "./widget-wrappers/reauth-example/reauth-example.component";
import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-test/manager-test.component"; import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-test/manager-test.component";
import { AdminTermsComponent } from "./admin/terms/terms.component";
const routes: Routes = [ const routes: Routes = [
{path: "", component: HomeComponent}, {path: "", component: HomeComponent},
@ -145,7 +146,17 @@ const routes: Routes = [
component: AdminStickerPacksComponent, component: AdminStickerPacksComponent,
}, },
], ],
} },
{
path: "terms",
data: {breadcrumb: "Terms of Service", name: "Terms of Service"},
children: [
{
path: "",
component: AdminTermsComponent,
},
],
},
], ],
}, },
{ {

View File

@ -0,0 +1,11 @@
export interface FE_TermsEditable {
shortcode: string;
version: string;
languages: {
[lang: string]: {
name: string;
url: string;
text?: string;
};
};
}

View File

@ -0,0 +1,15 @@
import { Injectable } from "@angular/core";
import { AuthedApi } from "../authed-api";
import { HttpClient } from "@angular/common/http";
import { FE_TermsEditable } from "../../models/terms";
@Injectable()
export class AdminTermsApiService extends AuthedApi {
constructor(http: HttpClient) {
super(http, true);
}
public getAllPolicies(): Promise<FE_TermsEditable[]> {
return this.authedGet<FE_TermsEditable[]>("/api/v1/dimension/admin/terms/all").toPromise();
}
}

View File

@ -3,24 +3,34 @@ import { SessionStorage } from "../SessionStorage";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
export class AuthedApi { export class AuthedApi {
constructor(protected http: HttpClient) { constructor(protected http: HttpClient, private mscAuth = false) {
} }
protected authedGet<T>(url: string, qs?: any): Observable<T> { protected authedGet<T>(url: string, qs?: any): Observable<T> {
if (!qs) qs = {}; const opts = this.fillAuthOptions(null, qs, null);
qs["scalar_token"] = SessionStorage.scalarToken; return this.http.get<T>(url, opts);
return this.http.get<T>(url, {params: qs});
} }
protected authedPost<T>(url: string, body?: any): Observable<T> { protected authedPost<T>(url: string, body?: any): Observable<T> {
if (!body) body = {}; if (!body) body = {};
const qs = {scalar_token: SessionStorage.scalarToken}; const opts = this.fillAuthOptions(null, null, null);
return this.http.post<T>(url, body, {params: qs}); return this.http.post<T>(url, body, opts);
} }
protected authedDelete<T>(url: string, qs?: any): Observable<T> { protected authedDelete<T>(url: string, qs?: any): Observable<T> {
const opts = this.fillAuthOptions(null, qs, null);
return this.http.delete<T>(url, opts);
}
private fillAuthOptions(opts: any, qs: any, headers: any): { headers: any, params: any } {
if (!opts) opts = {};
if (!qs) qs = {}; if (!qs) qs = {};
if (!headers) headers = {};
if (this.mscAuth) {
headers["Authorization"] = `Bearer ${SessionStorage.scalarToken}`;
} else {
qs["scalar_token"] = SessionStorage.scalarToken; qs["scalar_token"] = SessionStorage.scalarToken;
return this.http.delete<T>(url, {params: qs}); }
return Object.assign({}, opts, {params: qs, headers: headers});
} }
} }