CollectionMembers: add pages to control collection memberships.
parent
0bb7867059
commit
2f565994b7
|
@ -0,0 +1,120 @@
|
|||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import * as Etebase from "etebase";
|
||||
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Checkbox from "@material-ui/core/Checkbox";
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||
|
||||
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||
import PrettyFingerprint from "../widgets/PrettyFingerprint";
|
||||
import { CachedCollection } from "../Pim/helpers";
|
||||
import { useCredentials } from "../credentials";
|
||||
|
||||
interface PropsType {
|
||||
collection: CachedCollection;
|
||||
onOk: (username: string, publicKey: Uint8Array, accessLevel: Etebase.CollectionAccessLevel) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CollectionMemberAddDialog(props: PropsType) {
|
||||
const etebase = useCredentials()!;
|
||||
const [addUser, setAddUser] = React.useState("");
|
||||
const [publicKey, setPublicKey] = React.useState<Uint8Array>();
|
||||
const [readOnly, setReadOnly] = React.useState(false);
|
||||
const [userChosen, setUserChosen] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error>();
|
||||
|
||||
async function onAddRequest(_user: string) {
|
||||
setUserChosen(true);
|
||||
const inviteMgr = etebase.getInvitationManager();
|
||||
try {
|
||||
const userProfile = await inviteMgr.fetchUserProfile(addUser);
|
||||
setPublicKey(userProfile.pubkey);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
props.onOk(addUser, publicKey!, readOnly ? Etebase.CollectionAccessLevel.ReadOnly : Etebase.CollectionAccessLevel.ReadWrite);
|
||||
}
|
||||
|
||||
const { onClose } = props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmationDialog
|
||||
title="Error adding member"
|
||||
labelOk="OK"
|
||||
open
|
||||
onOk={onClose}
|
||||
onCancel={onClose}
|
||||
>
|
||||
User ({addUser}) not found. Have they setup their encryption password from one of the apps?
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (publicKey) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmationDialog
|
||||
title="Verify security fingerprint"
|
||||
labelOk="OK"
|
||||
open
|
||||
onOk={onOk}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p>
|
||||
Verify {addUser}'s security fingerprint to ensure the encryption is secure.
|
||||
</p>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<PrettyFingerprint publicKey={publicKey} />
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<ConfirmationDialog
|
||||
title="Invite user"
|
||||
labelOk="OK"
|
||||
open={!userChosen}
|
||||
onOk={onAddRequest}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{userChosen ?
|
||||
<LoadingIndicator />
|
||||
:
|
||||
<>
|
||||
<TextField
|
||||
name="addUser"
|
||||
placeholder="Username"
|
||||
style={{ width: "100%" }}
|
||||
value={addUser}
|
||||
onChange={(ev) => setAddUser(ev.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={readOnly}
|
||||
onChange={(event) => setReadOnly(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Read only?"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import * as Etebase from "etebase";
|
||||
|
||||
import { List, ListItem } from "../widgets/List";
|
||||
|
||||
import IconMemberAdd from "@material-ui/icons/PersonAdd";
|
||||
import VisibilityIcon from "@material-ui/icons/Visibility";
|
||||
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import Container from "../widgets/Container";
|
||||
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||
|
||||
import { useCredentials } from "../credentials";
|
||||
import { getCollectionManager } from "../etebase-helpers";
|
||||
import { CachedCollection } from "../Pim/helpers";
|
||||
|
||||
import CollectionMemberAddDialog from "./CollectionMemberAddDialog";
|
||||
|
||||
interface PropsType {
|
||||
collection: CachedCollection;
|
||||
}
|
||||
|
||||
export default function CollectionMembers(props: PropsType) {
|
||||
const etebase = useCredentials()!;
|
||||
const [members_, setMembers] = React.useState<Etebase.CollectionMember[]>();
|
||||
const [revokeUser, setRevokeUser] = React.useState<string | null>(null);
|
||||
const [addMemberOpen, setAddMemberOpen] = React.useState(false);
|
||||
// FIXME: add error handling
|
||||
|
||||
const { collection, metadata } = props.collection;
|
||||
|
||||
async function fetchMembers() {
|
||||
const colMgr = getCollectionManager(etebase);
|
||||
const memberManager = colMgr.getMemberManager(collection);
|
||||
const members = await memberManager.list();
|
||||
setMembers(members.data);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchMembers();
|
||||
}, []);
|
||||
|
||||
function onRevokeRequest(user: string) {
|
||||
setRevokeUser(user);
|
||||
}
|
||||
|
||||
async function onRevokeDo() {
|
||||
const colMgr = getCollectionManager(etebase);
|
||||
const memberManager = colMgr.getMemberManager(collection);
|
||||
await memberManager.remove(revokeUser!);
|
||||
await fetchMembers();
|
||||
setRevokeUser(null);
|
||||
}
|
||||
|
||||
async function onMemberAdd(username: string, pubkey: Uint8Array, accessLevel: Etebase.CollectionAccessLevel) {
|
||||
const inviteMgr = etebase.getInvitationManager();
|
||||
await inviteMgr.invite(collection, username, pubkey, accessLevel);
|
||||
await fetchMembers();
|
||||
setAddMemberOpen(false);
|
||||
}
|
||||
|
||||
const members = members_?.filter((x) => x.username !== etebase.user.username);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBarOverride title={`${metadata.name} - Members`} />
|
||||
<Container style={{ maxWidth: "30rem" }}>
|
||||
{members ?
|
||||
<List>
|
||||
<ListItem rightIcon={<IconMemberAdd />} onClick={() => setAddMemberOpen(true)}>
|
||||
Invite user
|
||||
</ListItem>
|
||||
{(members.length > 0 ?
|
||||
members.map((member) => (
|
||||
<ListItem
|
||||
key={member.username}
|
||||
onClick={() => onRevokeRequest(member.username)}
|
||||
rightIcon={(member.accessLevel === Etebase.CollectionAccessLevel.ReadOnly) ? (<VisibilityIcon />) : undefined}
|
||||
>
|
||||
{member.username}
|
||||
</ListItem>
|
||||
))
|
||||
:
|
||||
<ListItem>
|
||||
No members
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
:
|
||||
<LoadingIndicator />
|
||||
}
|
||||
</Container>
|
||||
<ConfirmationDialog
|
||||
title="Remove member"
|
||||
labelOk="OK"
|
||||
open={revokeUser !== null}
|
||||
onOk={onRevokeDo}
|
||||
onCancel={() => setRevokeUser(null)}
|
||||
>
|
||||
Would you like to revoke {revokeUser}'s access?<br />
|
||||
Please be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information.
|
||||
</ConfirmationDialog>
|
||||
|
||||
{addMemberOpen &&
|
||||
<CollectionMemberAddDialog
|
||||
collection={props.collection}
|
||||
onOk={onMemberAdd}
|
||||
onClose={() => setAddMemberOpen(false)}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -16,6 +16,7 @@ import CollectionList from "./CollectionList";
|
|||
import CollectionImport from "./CollectionImport";
|
||||
import PageNotFound from "../PageNotFound";
|
||||
import CollectionEdit from "./CollectionEdit";
|
||||
import CollectionMembers from "./CollectionMembers";
|
||||
import Collection from "./Collection";
|
||||
|
||||
const decryptCollections = getDecryptCollectionsFunction();
|
||||
|
@ -118,7 +119,7 @@ export default function CollectionsMain() {
|
|||
path={routeResolver.getRoute("collections._id.members")}
|
||||
exact
|
||||
>
|
||||
Members
|
||||
<CollectionMembers collection={collection} />
|
||||
</Route>
|
||||
<Route
|
||||
path={routeResolver.getRoute("collections._id")}
|
||||
|
|
|
@ -80,7 +80,7 @@ export default function JournalMemberAddDialog(props: PropsType) {
|
|||
Verify {addUser}'s security fingerprint to ensure the encryption is secure.
|
||||
</p>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<PrettyFingerprint publicKey={publicKey} />
|
||||
<PrettyFingerprint publicKey={publicKey as any} />
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
|
|
|
@ -18,15 +18,12 @@ import { setSettings } from "../store/actions";
|
|||
import Container from "../widgets/Container";
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import PrettyFingerprint from "../widgets/PrettyFingerprint";
|
||||
import { useCredentials } from "../credentials";
|
||||
|
||||
function SecurityFingerprint() {
|
||||
const userInfo = useSelector((state: StoreState) => state.cache.userInfo);
|
||||
|
||||
if (!userInfo) {
|
||||
return <p>Security fingerprint error.</p>;
|
||||
}
|
||||
|
||||
const publicKey = userInfo.publicKey;
|
||||
const etebase = useCredentials()!;
|
||||
const inviteMgr = etebase.getInvitationManager();
|
||||
const publicKey = inviteMgr.pubkey;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -2,50 +2,17 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
import sjcl from "sjcl";
|
||||
|
||||
import { byte, base64 } from "etesync";
|
||||
|
||||
function byteArray4ToNumber(bytes: byte[], offset: number) {
|
||||
// tslint:disable:no-bitwise
|
||||
return (
|
||||
((bytes[offset + 0] & 0xff) * (1 << 24)) +
|
||||
((bytes[offset + 1] & 0xff) * (1 << 16)) +
|
||||
((bytes[offset + 2] & 0xff) * (1 << 8)) +
|
||||
((bytes[offset + 3] & 0xff))
|
||||
);
|
||||
}
|
||||
|
||||
function getEncodedChunk(publicKey: byte[], offset: number) {
|
||||
const chunk = byteArray4ToNumber(publicKey, offset) % 100000;
|
||||
return chunk.toString().padStart(5, "0");
|
||||
}
|
||||
import * as Etebase from "etebase";
|
||||
|
||||
interface PropsType {
|
||||
publicKey: base64;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
class PrettyFingerprint extends React.PureComponent<PropsType> {
|
||||
public render() {
|
||||
const fingerprint = sjcl.codec.bytes.fromBits(
|
||||
sjcl.hash.sha256.hash(sjcl.codec.base64.toBits(this.props.publicKey))
|
||||
);
|
||||
export default function PrettyFingerprint(props: PropsType) {
|
||||
const prettyFingerprint = Etebase.getPrettyFingerprint(props.publicKey);
|
||||
|
||||
const spacing = " ";
|
||||
const prettyPublicKey =
|
||||
getEncodedChunk(fingerprint, 0) + spacing +
|
||||
getEncodedChunk(fingerprint, 4) + spacing +
|
||||
getEncodedChunk(fingerprint, 8) + spacing +
|
||||
getEncodedChunk(fingerprint, 12) + "\n" +
|
||||
getEncodedChunk(fingerprint, 16) + spacing +
|
||||
getEncodedChunk(fingerprint, 20) + spacing +
|
||||
getEncodedChunk(fingerprint, 24) + spacing +
|
||||
getEncodedChunk(fingerprint, 28);
|
||||
|
||||
return (
|
||||
<pre>{prettyPublicKey}</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre>{prettyFingerprint}</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrettyFingerprint;
|
||||
|
|
Loading…
Reference in New Issue