diff --git a/src/api/admin/AdminTermsService.ts b/src/api/admin/AdminTermsService.ts index 9e4c0bb..e403b21 100644 --- a/src/api/admin/AdminTermsService.ts +++ b/src/api/admin/AdminTermsService.ts @@ -1,4 +1,4 @@ -import { GET, Path, PathParam, POST, Security } from "typescript-rest"; +import { GET, Path, PathParam, POST, PUT, 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"; @@ -39,4 +39,18 @@ export class AdminTermsService { public async createDraftPolicy(@PathParam("shortcode") shortcode: string, request: CreatePolicyObject): Promise { return this.termsController.createDraftPolicy(request.name, shortcode, request.text, request.url); } + + @POST + @Path(":shortcode/publish/:version") + @Security([ROLE_MSC_USER, ROLE_MSC_ADMIN]) + public async publishDraftPolicy(@PathParam("shortcode") shortcode: string, @PathParam("version") version: string): Promise { + return this.termsController.publishPolicy(shortcode, version); + } + + @PUT + @Path(":shortcode/:version") + @Security([ROLE_MSC_USER, ROLE_MSC_ADMIN]) + public async updatePolicy(@PathParam("shortcode") shortcode: string, @PathParam("version") version: string, request: CreatePolicyObject): Promise { + return this.termsController.updatePolicy(request.name, shortcode, version, request.text, request.url); + } } \ No newline at end of file diff --git a/src/api/controllers/TermsController.ts b/src/api/controllers/TermsController.ts index 20cb6c2..6b9a67a 100644 --- a/src/api/controllers/TermsController.ts +++ b/src/api/controllers/TermsController.ts @@ -68,6 +68,32 @@ export default class TermsController { return this.mapPolicy(true, terms); } + public async updatePolicy(name: string, shortcode: string, version: string, text: string, url: string): Promise { + const terms = await TermsRecord.findOne({where: {shortcode, version}, include: [TermsTextRecord]}); + const termsText = terms.texts.find(e => e.language === "en"); + + termsText.url = url; + termsText.text = text; + termsText.name = name; + + await termsText.save(); + + return this.mapPolicy(true, terms); + } + + public async publishPolicy(shortcode: string, targetVersion: string): Promise { + const terms = await TermsRecord.findOne({ + where: {shortcode, version: VERSION_DRAFT}, + include: [TermsTextRecord], + }); + if (!terms) throw new Error("Missing terms"); + + terms.version = targetVersion; + await terms.save(); + + return this.mapPolicy(true, terms); + } + private mapPolicy(withText: boolean, policy: TermsRecord): ITerms { const languages = {}; policy.texts.forEach(pt => languages[pt.language] = { diff --git a/web/app/admin/terms/new/new.component.html b/web/app/admin/terms/new-edit/new-edit.component.html similarity index 76% rename from web/app/admin/terms/new/new.component.html rename to web/app/admin/terms/new-edit/new-edit.component.html index dc4f693..3f50018 100644 --- a/web/app/admin/terms/new/new.component.html +++ b/web/app/admin/terms/new-edit/new-edit.component.html @@ -14,7 +14,7 @@ @@ -31,9 +31,15 @@
- + +
\ No newline at end of file diff --git a/web/app/admin/terms/new/new.component.scss b/web/app/admin/terms/new-edit/new-edit.component.scss similarity index 100% rename from web/app/admin/terms/new/new.component.scss rename to web/app/admin/terms/new-edit/new-edit.component.scss diff --git a/web/app/admin/terms/new-edit/new-edit.component.ts b/web/app/admin/terms/new-edit/new-edit.component.ts new file mode 100644 index 0000000..07a9a98 --- /dev/null +++ b/web/app/admin/terms/new-edit/new-edit.component.ts @@ -0,0 +1,213 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { AdminTermsApiService } from "../../../shared/services/admin/admin-terms-api.service"; +import { ActivatedRoute, Router } from "@angular/router"; +import * as ClassicEditor from '@ckeditor/ckeditor5-build-classic'; +import ISO6391 from "iso-639-1"; +import { Modal, overlayConfigFactory } from "ngx-modialog"; +import { + AdminTermsNewEditPublishDialogComponent, + AdminTermsNewEditPublishDialogContext +} from "./publish/publish.component"; + +interface ILanguage { + name: string, + text: string, + langName: string, + url: string, + isExternal: boolean, + externalUrl: string, +} + +@Component({ + templateUrl: "./new-edit.component.html", + styleUrls: ["./new-edit.component.scss"], +}) +export class AdminNewEditTermsComponent implements OnInit { + + // TODO: Multiple language support + // TODO: Support external URLs + + private shortcode: string; + + public Editor = ClassicEditor; + + public isLoading = true; + public isUpdating = false; + public takenShortcodes: string[]; + public chosenLanguage: string = ISO6391.getAllCodes()[0]; + public languages: { + [languageCode: string]: ILanguage; + } = { + "en": { + name: "", + text: "", + langName: "English", + url: "", // TODO: Calculate + isExternal: false, + externalUrl: "", + }, + }; + public isEditing = false; + + public get chosenLanguageCodes(): string[] { + return Object.keys(this.languages); + } + + public get availableLanguages(): { name: string, code: string }[] { + return ISO6391.getAllCodes() + .filter(c => !this.chosenLanguageCodes.includes(c)) + .map(c => { + return {code: c, name: ISO6391.getName(c)}; + }); + } + + constructor(private adminTerms: AdminTermsApiService, + private toaster: ToasterService, + private router: Router, + private activatedRoute: ActivatedRoute, + private modal: Modal) { + } + + public ngOnInit() { + let params = this.activatedRoute.snapshot.params; + this.shortcode = params.shortcode; + this.isEditing = !!this.shortcode; + + if (this.isEditing) { + this.adminTerms.getDraft(this.shortcode).then(policy => { + this.shortcode = policy.shortcode; + this.languages = {}; + + for (const code in policy.languages) { + const i18nPolicy = policy.languages[code]; + this.languages[code] = { + text: i18nPolicy.text, + url: i18nPolicy.url, + name: i18nPolicy.name, + isExternal: false, + langName: ISO6391.getName(code), + externalUrl: "", + }; + } + + this.isLoading = false; + }).catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to load policy"); + }); + } else { + this.adminTerms.getAllPolicies().then(policies => { + this.takenShortcodes = policies.map(p => p.shortcode); + this.isLoading = false; + }).catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to load policies"); + }); + } + } + + public async publish() { + this.isUpdating = true; + + await this.adminTerms.updateDraft(this.shortcode, { + name: this.languages['en'].name, + text: this.languages['en'].text, + url: `${window.location.origin}/widgets/terms/${this.shortcode}/en/draft`, + }); + + + this.modal.open(AdminTermsNewEditPublishDialogComponent, overlayConfigFactory({ + isBlocking: true, + size: 'sm', + }, AdminTermsNewEditPublishDialogContext)).result.then(async (val) => { + if (!val) return; // closed without publish + + try { + // Change the URL of the draft + // TODO: Don't track URLs for drafts + await this.adminTerms.updateDraft(this.shortcode, { + name: this.languages['en'].name, + text: this.languages['en'].text, + url: `${window.location.origin}/widgets/terms/${this.shortcode}/en/${val}`, + }); + + await this.adminTerms.publishDraft(this.shortcode, val); + this.toaster.pop("success", "Policy published"); + this.router.navigate(["../.."], {relativeTo: this.activatedRoute}); + } catch (e) { + console.error(e); + this.toaster.pop("error", "Error publishing policy"); + this.isUpdating = false; + } + }); + } + + public async create() { + for (const languageCode in this.languages) { + if (this.languages[languageCode].name.trim().length <= 0) { + this.toaster.pop("warning", "Please enter a name for all policies"); + return; + } + if (this.languages[languageCode].text.trim().length <= 0) { + this.toaster.pop("warning", "Please enter text for all policies"); + return; + } + } + + this.isUpdating = true; + + if (this.isEditing) { + try { + await this.adminTerms.updateDraft(this.shortcode, { + name: this.languages['en'].name, + text: this.languages['en'].text, + url: `${window.location.origin}/widgets/terms/${this.shortcode}/en/draft`, + }); + + this.toaster.pop("success", "Draft saved"); + this.router.navigate(["../.."], {relativeTo: this.activatedRoute}); + } catch (e) { + console.error(e); + this.toaster.pop("error", "Error saving policy"); + this.isUpdating = false; + } + return; + } + + const startShortcode = this.languages['en'].name.toLowerCase().replace(/[^a-z0-9]/gi, '_'); + let shortcode = startShortcode; + let i = 0; + while (this.takenShortcodes.includes(shortcode)) { + shortcode = `${startShortcode}_${++i}`; + } + + try { + await this.adminTerms.createDraft(shortcode, { + name: this.languages['en'].name, + text: this.languages['en'].text, + url: `${window.location.origin}/widgets/terms/${shortcode}/en/draft`, + }); + + this.toaster.pop("success", "Draft created"); + this.router.navigate([".."], {relativeTo: this.activatedRoute}); + } catch (e) { + console.error(e); + this.toaster.pop("error", "Error creating document"); + this.isUpdating = false; + } + } + + public addLanguage() { + this.languages[this.chosenLanguage] = { + name: "", + text: "", + url: "", // TODO: Calculate + isExternal: false, + externalUrl: "", + langName: ISO6391.getName(this.chosenLanguage), + }; + this.chosenLanguage = this.availableLanguages[0].code; + } + +} diff --git a/web/app/admin/terms/new-edit/publish/publish.component.html b/web/app/admin/terms/new-edit/publish/publish.component.html new file mode 100644 index 0000000..2128780 --- /dev/null +++ b/web/app/admin/terms/new-edit/publish/publish.component.html @@ -0,0 +1,20 @@ +
+
+

Publish policy

+
+
+ +
+ +
\ No newline at end of file diff --git a/web/app/admin/terms/new-edit/publish/publish.component.scss b/web/app/admin/terms/new-edit/publish/publish.component.scss new file mode 100644 index 0000000..6de51bf --- /dev/null +++ b/web/app/admin/terms/new-edit/publish/publish.component.scss @@ -0,0 +1,3 @@ +button { + margin-right: 5px; +} \ No newline at end of file diff --git a/web/app/admin/terms/new-edit/publish/publish.component.ts b/web/app/admin/terms/new-edit/publish/publish.component.ts new file mode 100644 index 0000000..993f7b7 --- /dev/null +++ b/web/app/admin/terms/new-edit/publish/publish.component.ts @@ -0,0 +1,27 @@ +import { Component } from "@angular/core"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; +import { ToasterService } from "angular2-toaster"; + +export class AdminTermsNewEditPublishDialogContext extends BSModalContext { +} + +@Component({ + templateUrl: "./publish.component.html", + styleUrls: ["./publish.component.scss"], +}) +export class AdminTermsNewEditPublishDialogComponent implements ModalComponent { + + public version: string; + + constructor(public dialog: DialogRef, private toaster: ToasterService) { + } + + public publish() { + if (!this.version || !this.version.trim()) { + this.toaster.pop("warning", "Please enter a version number"); + return; + } + this.dialog.close(this.version); + } +} diff --git a/web/app/admin/terms/new/new.component.ts b/web/app/admin/terms/new/new.component.ts deleted file mode 100644 index 5259d97..0000000 --- a/web/app/admin/terms/new/new.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Component, OnInit } from "@angular/core"; -import { ToasterService } from "angular2-toaster"; -import { AdminTermsApiService } from "../../../shared/services/admin/admin-terms-api.service"; -import { ActivatedRoute, Router } from "@angular/router"; -import * as ClassicEditor from '@ckeditor/ckeditor5-build-classic'; -import ISO6391 from "iso-639-1"; - -interface ILanguage { - name: string, - text: string, - langName: string, - url: string, - isExternal: boolean, - externalUrl: string, -} - -@Component({ - templateUrl: "./new.component.html", - styleUrls: ["./new.component.scss"], -}) -export class AdminNewTermsComponent implements OnInit { - - // TODO: Multiple language support - // TODO: Support external URLs - - public Editor = ClassicEditor; - - public isLoading = true; - public isUpdating = false; - public takenShortcodes: string[]; - public chosenLanguage: string = ISO6391.getAllCodes()[0]; - public languages: { - [languageCode: string]: ILanguage; - } = { - "en": { - name: "", - text: "", - langName: "English", - url: "", // TODO: Calculate - isExternal: false, - externalUrl: "", - }, - }; - - public get chosenLanguageCodes(): string[] { - return Object.keys(this.languages); - } - - public get availableLanguages(): { name: string, code: string }[] { - return ISO6391.getAllCodes() - .filter(c => !this.chosenLanguageCodes.includes(c)) - .map(c => { - return {code: c, name: ISO6391.getName(c)}; - }); - } - - constructor(private adminTerms: AdminTermsApiService, - private toaster: ToasterService, - private router: Router, - private activatedRoute: ActivatedRoute) { - } - - public ngOnInit() { - this.adminTerms.getAllPolicies().then(policies => { - this.takenShortcodes = policies.map(p => p.shortcode); - this.isLoading = false; - }).catch(err => { - console.error(err); - this.toaster.pop("error", "Failed to load policies"); - }); - } - - public async create() { - for (const languageCode in this.languages) { - if (this.languages[languageCode].name.trim().length <= 0) { - this.toaster.pop("warning", "Please enter a name for all policies"); - return; - } - if (this.languages[languageCode].text.trim().length <= 0) { - this.toaster.pop("warning", "Please enter text for all policies"); - return; - } - } - - this.isUpdating = true; - - const startShortcode = this.languages['en'].name.toLowerCase().replace(/[^a-z0-9]/gi, '_'); - let shortcode = startShortcode; - let i = 0; - while (this.takenShortcodes.includes(shortcode)) { - shortcode = `${startShortcode}_${++i}`; - } - - try { - await this.adminTerms.createDraft(shortcode, { - name: this.languages['en'].name, - text: this.languages['en'].text, - url: `${window.location.origin}/terms/${shortcode}/en/draft`, - }); - - this.toaster.pop("success", "Draft created"); - this.router.navigate([".."], {relativeTo: this.activatedRoute}); - } catch (e) { - console.error(e); - this.toaster.pop("error", "Error creating document"); - this.isUpdating = false; - } - } - - public addLanguage() { - this.languages[this.chosenLanguage] = { - name: "", - text: "", - url: "", // TODO: Calculate - isExternal: false, - externalUrl: "", - langName: ISO6391.getName(this.chosenLanguage), - }; - this.chosenLanguage = this.availableLanguages[0].code; - } - -} diff --git a/web/app/admin/terms/terms.component.html b/web/app/admin/terms/terms.component.html index eb841c7..33c08f1 100644 --- a/web/app/admin/terms/terms.component.html +++ b/web/app/admin/terms/terms.component.html @@ -28,7 +28,7 @@ {{ policy.version }} - + diff --git a/web/app/admin/terms/terms.component.scss b/web/app/admin/terms/terms.component.scss index e69de29..788d7ed 100644 --- a/web/app/admin/terms/terms.component.scss +++ b/web/app/admin/terms/terms.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/terms/terms.component.ts b/web/app/admin/terms/terms.component.ts index ef16e92..40b20ff 100644 --- a/web/app/admin/terms/terms.component.ts +++ b/web/app/admin/terms/terms.component.ts @@ -10,6 +10,9 @@ import { ActivatedRoute, Router } from "@angular/router"; }) export class AdminTermsComponent implements OnInit { + // TODO: "New draft" per policy button + // TODO: Delete button + public isLoading = true; public policies: FE_TermsEditable[]; diff --git a/web/app/app.module.ts b/web/app/app.module.ts index cb301f9..7f4aafe 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -114,8 +114,9 @@ import { ReauthExampleWidgetWrapperComponent } from "./widget-wrappers/reauth-ex 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"; -import { AdminNewTermsComponent } from "./admin/terms/new/new.component"; import { CKEditorModule } from "@ckeditor/ckeditor5-angular"; +import { AdminNewEditTermsComponent } from "./admin/terms/new-edit/new-edit.component"; +import { AdminTermsNewEditPublishDialogComponent } from "./admin/terms/new-edit/publish/publish.component"; @NgModule({ imports: [ @@ -210,7 +211,8 @@ import { CKEditorModule } from "@ckeditor/ckeditor5-angular"; ReauthExampleWidgetWrapperComponent, ManagerTestWidgetWrapperComponent, AdminTermsComponent, - AdminNewTermsComponent, + AdminNewEditTermsComponent, + AdminTermsNewEditPublishDialogComponent, // Vendor ], @@ -266,6 +268,7 @@ import { CKEditorModule } from "@ckeditor/ckeditor5-angular"; AdminAddCustomBotComponent, AdminSlackBridgeManageSelfhostedComponent, AdminLogoutConfirmationDialogComponent, + AdminTermsNewEditPublishDialogComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index 4d629f7..c3d5388 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -45,7 +45,7 @@ import { SlackBridgeConfigComponent } from "./configs/bridge/slack/slack.bridge. import { ReauthExampleWidgetWrapperComponent } from "./widget-wrappers/reauth-example/reauth-example.component"; import { ManagerTestWidgetWrapperComponent } from "./widget-wrappers/manager-test/manager-test.component"; import { AdminTermsComponent } from "./admin/terms/terms.component"; -import { AdminNewTermsComponent } from "./admin/terms/new/new.component"; +import { AdminNewEditTermsComponent } from "./admin/terms/new-edit/new-edit.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -158,9 +158,14 @@ const routes: Routes = [ }, { path: "new", - component: AdminNewTermsComponent, + component: AdminNewEditTermsComponent, data: {breadcrumb: "New policy", name: "New policy"}, }, + { + path: "edit/:shortcode", + component: AdminNewEditTermsComponent, + data: {breadcrumb: "Edit policy", name: "Edit policy"}, + }, ], }, ], diff --git a/web/app/shared/services/admin/admin-terms-api.service.ts b/web/app/shared/services/admin/admin-terms-api.service.ts index e576372..87e6813 100644 --- a/web/app/shared/services/admin/admin-terms-api.service.ts +++ b/web/app/shared/services/admin/admin-terms-api.service.ts @@ -16,4 +16,16 @@ export class AdminTermsApiService extends AuthedApi { public createDraft(shortcode: string, policyInfo: { name: string, text: string, url: string }): Promise { return this.authedPost(`/api/v1/dimension/admin/terms/${shortcode}/draft`, policyInfo).toPromise(); } + + public getDraft(shortcode: string): Promise { + return this.authedGet(`/api/v1/dimension/admin/terms/${shortcode}/draft`).toPromise(); + } + + public updateDraft(shortcode: string, policyInfo: { name: string, text: string, url: string }): Promise { + return this.authedPut(`/api/v1/dimension/admin/terms/${shortcode}/draft`, policyInfo).toPromise(); + } + + public publishDraft(shortcode: string, newVersion: string): Promise { + return this.authedPost(`/api/v1/dimension/admin/terms/${shortcode}/publish/${newVersion}`).toPromise(); + } } diff --git a/web/app/shared/services/authed-api.ts b/web/app/shared/services/authed-api.ts index 2eeecb0..266a219 100644 --- a/web/app/shared/services/authed-api.ts +++ b/web/app/shared/services/authed-api.ts @@ -17,6 +17,12 @@ export class AuthedApi { return this.http.post(url, body, opts); } + protected authedPut(url: string, body?: any): Observable { + if (!body) body = {}; + const opts = this.fillAuthOptions(null, null, null); + return this.http.put(url, body, opts); + } + protected authedDelete(url: string, qs?: any): Observable { const opts = this.fillAuthOptions(null, qs, null); return this.http.delete(url, opts);