Developer design a permissions dialog

pull/21833/head
Travis Ralston 2020-11-17 20:38:59 -07:00
parent 94550546eb
commit a212dab84c
8 changed files with 382 additions and 34 deletions

View File

@ -91,6 +91,7 @@
@import "./views/dialogs/_TermsDialog.scss";
@import "./views/dialogs/_UploadConfirmDialog.scss";
@import "./views/dialogs/_UserSettingsDialog.scss";
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss";
@import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss";
@import "./views/dialogs/security/_AccessSecretStorageDialog.scss";
@import "./views/dialogs/security/_CreateCrossSigningDialog.scss";

View File

@ -0,0 +1,46 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_WidgetCapabilitiesPromptDialog {
.mx_Dialog_content {
margin-bottom: 16px;
}
.mx_WidgetCapabilitiesPromptDialog_cap {
margin-top: 8px;
.mx_WidgetCapabilitiesPromptDialog_byline {
color: $muted-fg-color;
margin-left: 26px;
}
}
.mx_SettingsFlag {
margin-top: 24px;
.mx_ToggleSwitch {
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.mx_SettingsFlag_label {
display: inline-block;
vertical-align: middle;
}
}
}

View File

@ -0,0 +1,252 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import BaseDialog from "./BaseDialog";
import { _t, _td, TranslatedString } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import { Capability, EventDirection, MatrixCapabilities, Widget, WidgetEventCapability } from "matrix-widget-api";
import { objectShallowClone } from "../../../utils/objects";
import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities";
import { EventType, MsgType } from "matrix-js-sdk/lib/@types/event";
import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
// TODO: These messaging things can probably get their own store of some sort
const SIMPLE_CAPABILITY_MESSAGES = {
[MatrixCapabilities.AlwaysOnScreen]: _td("Remain on your screen while running"),
[MatrixCapabilities.StickerSending]: _td("Send stickers into your active room"),
[ElementWidgetCapabilities.CanChangeViewedRoom]: _td("Change which room you're viewing"),
};
const SEND_RECV_EVENT_CAPABILITY_MESSAGES = {
[EventType.RoomTopic]: {
// TODO: We probably want to say "this room" when we can
[EventDirection.Send]: _td("Change the topic of your active room"),
[EventDirection.Receive]: _td("See when the topic changes in your active room"),
},
[EventType.RoomName]: {
[EventDirection.Send]: _td("Change the name of your active room"),
[EventDirection.Receive]: _td("See when the name changes in your active room"),
},
[EventType.RoomAvatar]: {
[EventDirection.Send]: _td("Change the avatar of your active room"),
[EventDirection.Receive]: _td("See when the avatar changes in your active room"),
},
// TODO: Add more as needed
};
function textForEventCapabilitiy(cap: WidgetEventCapability): { primary: TranslatedString, byline: TranslatedString } {
let primary: TranslatedString;
let byline: TranslatedString;
if (cap.isState) {
byline = cap.keyStr
? _t("with state key %(stateKey)s", {stateKey: cap.keyStr})
: _t("with an empty state key");
}
const srMessages = SEND_RECV_EVENT_CAPABILITY_MESSAGES[cap.eventType];
if (srMessages && srMessages[cap.direction]) {
primary = _t(srMessages[cap.direction]);
} else {
if (cap.eventType === EventType.RoomMessage) {
if (cap.direction === EventDirection.Receive) {
if (!cap.keyStr) {
primary = _t("See messages sent in your active room");
} else {
if (cap.keyStr === MsgType.Text) {
primary = _t("See text messages sent in your active room");
} else if (cap.keyStr === MsgType.Emote) {
primary = _t("See emotes sent in your active room");
} else if (cap.keyStr === MsgType.Image) {
primary = _t("See images sent in your active room");
} else if (cap.keyStr === MsgType.Video) {
primary = _t("See videos sent in your active room");
} else if (cap.keyStr === MsgType.File) {
primary = _t("See general files sent in your active room");
} else {
primary = _t(
"See <code>%(msgtype)s</code> messages sent in your active room",
{msgtype: cap.keyStr}, {code: sub => <code>{sub}</code>},
);
}
}
} else {
if (!cap.keyStr) {
primary = _t("Send messages as you in your active room");
} else {
if (cap.keyStr === MsgType.Text) {
primary = _t("Send text messages as you in your active room");
} else if (cap.keyStr === MsgType.Emote) {
primary = _t("Send emotes as you in your active room");
} else if (cap.keyStr === MsgType.Image) {
primary = _t("Send images as you in your active room");
} else if (cap.keyStr === MsgType.Video) {
primary = _t("Send videos as you in your active room");
} else if (cap.keyStr === MsgType.File) {
primary = _t("Send general files as you in your active room");
} else {
primary = _t(
"Send <code>%(msgtype)s</code> messages as you in your active room",
{msgtype: cap.keyStr}, {code: sub => <code>{sub}</code>},
);
}
}
}
} else {
if (cap.direction === EventDirection.Receive) {
primary = _t(
"See <code>%(eventType)s</code> events sent in your active room",
{eventType: cap.eventType}, {code: sub => <code>{sub}</code>},
);
} else {
primary = _t(
"Send <code>%(eventType)s</code> events as you in your active room",
{eventType: cap.eventType}, {code: sub => <code>{sub}</code>},
);
}
}
}
return {primary, byline};
}
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
widget: Widget;
}
interface IBooleanStates {
// @ts-ignore - TS wants a string key, but we know better
[capability: Capability]: boolean;
}
interface IState {
booleanStates: IBooleanStates;
rememberSelection: boolean;
}
export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<IProps, IState> {
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
constructor(props: IProps) {
super(props);
const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities);
parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e));
const states: IBooleanStates = {};
this.props.requestedCapabilities.forEach(c => states[c] = true);
this.state = {
booleanStates: states,
rememberSelection: true,
};
}
private onToggle = (capability: Capability) => {
const newStates = objectShallowClone(this.state.booleanStates);
newStates[capability] = !newStates[capability];
this.setState({booleanStates: newStates});
};
private onRememberSelectionChange = (newVal: boolean) => {
this.setState({rememberSelection: newVal});
};
private onSubmit = async (ev) => {
this.closeAndTryRemember(Object.entries(this.state.booleanStates)
.filter(([_, isSelected]) => isSelected)
.map(([cap]) => cap));
};
private onReject = async (ev) => {
this.closeAndTryRemember([]); // nothing was approved
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({approved});
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
const evCap = this.eventPermissionsMap.get(cap);
let text: TranslatedString;
let byline: TranslatedString;
if (evCap) {
const t = textForEventCapabilitiy(evCap);
text = t.primary;
byline = t.byline;
} else if (SIMPLE_CAPABILITY_MESSAGES[cap]) {
text = _t(SIMPLE_CAPABILITY_MESSAGES[cap]);
} else {
text = _t(
"The <code>%(capability)s</code> capability",
{capability: cap}, {code: sub => <code>{sub}</code>},
);
}
return (
<div className="mx_WidgetCapabilitiesPromptDialog_cap">
<StyledCheckbox
key={cap + i}
checked={isChecked}
onChange={() => this.onToggle(cap)}
>{text}</StyledCheckbox>
{byline ? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{byline}</span> : null}
</div>
);
});
return (
<BaseDialog
className="mx_WidgetCapabilitiesPromptDialog"
onFinished={this.props.onFinished}
title={_t("Approve widget permissions")}
>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
{_t("This widget would like to:")}
{checkboxRows}
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("Remember my selection for this widget")} />
<DialogButtons
primaryButton={_t("Approve")}
cancelButton={_t("Decline All")}
onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject}
/>
</div>
</form>
</BaseDialog>
);
}
}

View File

@ -2123,9 +2123,41 @@
"Upload Error": "Upload Error",
"Verify other session": "Verify other session",
"Verification Request": "Verification Request",
"Remain on your screen while running": "Remain on your screen while running",
"Send stickers into your active room": "Send stickers into your active room",
"Change which room you're viewing": "Change which room you're viewing",
"Change the topic of your active room": "Change the topic of your active room",
"See when the topic changes in your active room": "See when the topic changes in your active room",
"Change the name of your active room": "Change the name of your active room",
"See when the name changes in your active room": "See when the name changes in your active room",
"Change the avatar of your active room": "Change the avatar of your active room",
"See when the avatar changes in your active room": "See when the avatar changes in your active room",
"with state key %(stateKey)s": "with state key %(stateKey)s",
"with an empty state key": "with an empty state key",
"See messages sent in your active room": "See messages sent in your active room",
"See text messages sent in your active room": "See text messages sent in your active room",
"See emotes sent in your active room": "See emotes sent in your active room",
"See images sent in your active room": "See images sent in your active room",
"See videos sent in your active room": "See videos sent in your active room",
"See general files sent in your active room": "See general files sent in your active room",
"See <code>%(msgtype)s</code> messages sent in your active room": "See <code>%(msgtype)s</code> messages sent in your active room",
"Send messages as you in your active room": "Send messages as you in your active room",
"Send text messages as you in your active room": "Send text messages as you in your active room",
"Send emotes as you in your active room": "Send emotes as you in your active room",
"Send images as you in your active room": "Send images as you in your active room",
"Send videos as you in your active room": "Send videos as you in your active room",
"Send general files as you in your active room": "Send general files as you in your active room",
"Send <code>%(msgtype)s</code> messages as you in your active room": "Send <code>%(msgtype)s</code> messages as you in your active room",
"See <code>%(eventType)s</code> events sent in your active room": "See <code>%(eventType)s</code> events sent in your active room",
"Send <code>%(eventType)s</code> events as you in your active room": "Send <code>%(eventType)s</code> events as you in your active room",
"The <code>%(capability)s</code> capability": "The <code>%(capability)s</code> capability",
"Approve widget permissions": "Approve widget permissions",
"This widget would like to:": "This widget would like to:",
"Remember my selection for this widget": "Remember my selection for this widget",
"Approve": "Approve",
"Decline All": "Decline All",
"A widget would like to verify your identity": "A widget would like to verify your identity",
"A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.",
"Remember my selection for this widget": "Remember my selection for this widget",
"Allow": "Allow",
"Deny": "Deny",
"Wrong file type": "Wrong file type",

View File

@ -103,6 +103,8 @@ export interface IVariables {
type Tags = Record<string, (sub: string) => React.ReactNode>;
export type TranslatedString = string | React.ReactNode;
/*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
@ -121,7 +123,7 @@ type Tags = Record<string, (sub: string) => React.ReactNode>;
*/
export function _t(text: string, variables?: IVariables): string;
export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
export function _t(text: string, variables?: IVariables, tags?: Tags): TranslatedString {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too

View File

@ -32,7 +32,8 @@ import {
Widget,
WidgetApiToWidgetAction,
WidgetApiFromWidgetAction,
IModalWidgetOpenRequest, IWidgetApiErrorResponseData,
IModalWidgetOpenRequest,
IWidgetApiErrorResponseData,
} from "matrix-widget-api";
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
import { EventEmitter } from "events";
@ -302,7 +303,7 @@ export class StopGapWidget extends EventEmitter {
public start(iframe: HTMLIFrameElement) {
if (this.started) return;
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget.type);
const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready"));

View File

@ -14,47 +14,57 @@
* limitations under the License.
*/
import { Capability, ISendEventDetails, WidgetDriver, WidgetEventCapability, WidgetType } from "matrix-widget-api";
import { iterableUnion } from "../../utils/iterables";
import {
Capability,
ISendEventDetails,
MatrixCapabilities, Widget,
WidgetDriver,
} from "matrix-widget-api";
import { iterableDiff, iterableUnion } from "../../utils/iterables";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { arrayFastClone } from "../../utils/arrays";
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import ActiveRoomObserver from "../../ActiveRoomObserver";
import Modal from "../../Modal";
import WidgetCapabilitiesPromptDialog, { getRememberedCapabilitiesForWidget } from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
// TODO: Purge this from the universe
export class StopGapWidgetDriver extends WidgetDriver {
constructor(private allowedCapabilities: Capability[], private forType: WidgetType) {
private allowedCapabilities: Set<Capability>;
constructor(allowedCapabilities: Capability[], private forWidget: Widget) {
super();
// Always allow screenshots to be taken because it's a client-induced flow. The widget can't
// spew screenshots at us and can't request screenshots of us, so it's up to us to provide the
// button if the widget says it supports screenshots.
this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots]);
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
// TODO: All of this should be a capabilities prompt.
// See https://github.com/vector-im/element-web/issues/13111
// Note: None of this well-known widget permissions stuff is documented intentionally. We
// do not want to encourage people relying on this, but need to be able to support it at
// the moment.
//
// If you're a widget developer and seeing this message, please ask the Element team if
// it is safe for you to use this permissions system before trying to use it - it might
// not be here in the future.
const wkPerms = (MatrixClientPeg.get().getClientWellKnown() || {})['io.element.widget_permissions'];
const allowedCaps = arrayFastClone(this.allowedCapabilities);
if (wkPerms) {
if (Array.isArray(wkPerms["view_room_action"])) {
if (wkPerms["view_room_action"].includes(this.forType)) {
allowedCaps.push(ElementWidgetCapabilities.CanChangeViewedRoom);
}
}
if (Array.isArray(wkPerms["event_actions"])) {
if (wkPerms["event_actions"].includes(this.forType)) {
allowedCaps.push(...WidgetEventCapability.findEventCapabilities(requested).map(c => c.raw));
}
// Check to see if any capabilities aren't automatically accepted (such as sticker pickers
// allowing stickers to be sent). If there are excess capabilities to be approved, the user
// will be prompted to accept them.
const diff = iterableDiff(requested, this.allowedCapabilities);
const missing = new Set(diff.removed); // "removed" is "in A (requested) but not in B (allowed)"
const allowedSoFar = new Set(this.allowedCapabilities);
getRememberedCapabilitiesForWidget(this.forWidget).forEach(cap => allowedSoFar.add(cap));
// TODO: Do something when the widget requests new capabilities not yet asked for
if (missing.size > 0) {
try {
const [result] = await Modal.createTrackedDialog(
'Approve Widget Caps', '',
WidgetCapabilitiesPromptDialog,
{
requestedCapabilities: missing,
widget: this.forWidget,
}).finished;
(result.approved || []).forEach(cap => allowedSoFar.add(cap));
} catch (e) {
console.error("Non-fatal error getting capabilities: ", e);
}
}
return new Set(iterableUnion(requested, allowedCaps));
return new Set(iterableUnion(allowedSoFar, requested));
}
public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise<ISendEventDetails> {

View File

@ -14,8 +14,12 @@
* limitations under the License.
*/
import { arrayUnion } from "./arrays";
import { arrayDiff, arrayUnion } from "./arrays";
export function iterableUnion<T>(a: Iterable<T>, b: Iterable<T>): Iterable<T> {
return arrayUnion(Array.from(a), Array.from(b));
}
export function iterableDiff<T>(a: Iterable<T>, b: Iterable<T>): { added: Iterable<T>, removed: Iterable<T> } {
return arrayDiff(Array.from(a), Array.from(b));
}