Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ coverage

# Node.js
/headless/

# Local memory for AI agents
memory.md
docs/bootstrap-v3-analysis.md
38 changes: 38 additions & 0 deletions openmls-lite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,44 @@ impl Group {
.collect::<Vec<_>>()
.into_boxed_slice()
}

#[wasm_bindgen]
pub fn member_indexes_by_identity(&self, identity_value: &[u8]) -> Box<[u32]> {
self.group
.members()
.filter(|m| {
if m.credential.credential_type() != CredentialType::Basic {
return false;
}
let identity = match decode_workspace_credential(m.credential.serialized_content()) {
Ok((identity, _workspace_cert)) => identity,
Err(_) => return false,
};
identity == identity_value
})
.map(|m| m.index.u32())
.collect::<Vec<_>>()
.into_boxed_slice()
}

#[wasm_bindgen]
pub fn member_identities(&self) -> Result<js_sys::Array, JsValue> {
let out = js_sys::Array::new();
for member in self.group.members() {
if member.credential.credential_type() != CredentialType::Basic {
continue;
}
let (identity, _workspace_cert) = match decode_workspace_credential(
member.credential.serialized_content(),
) {
Ok(decoded) => decoded,
Err(_) => continue,
};
let identity = std::str::from_utf8(identity).map_err(err)?;
out.push(&JsValue::from_str(identity));
}
Ok(out)
}

#[wasm_bindgen]
pub fn export_secret(&self, label: &str, context: &[u8], len: usize) -> Result<Vec<u8>, JsValue> {
Expand Down
72 changes: 47 additions & 25 deletions src/components/InvitePeopleModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

<p v-if="canManageMembers">
Share the public join link for the traditional flow, or add recipients below to generate
per-person fast join links. Ownly does not automatically send emails.
per-person fast join links. The owner join link can be shared with an owner device that needs
to rejoin this workspace. Ownly does not automatically send emails.
</p>
<p v-else>
Share the public join link for the traditional flow. A master owner device must approve
access requests and manage per-person fast join links.
Share the public join link for the traditional flow. The owner join link can be shared with
an owner device that needs to rejoin this workspace. A master owner device must approve access
requests and manage per-person fast join links.
</p>

<div class="invite-link-box mt-3">
Expand All @@ -26,24 +28,24 @@
</div>
</div>

<template v-if="canManageMembers">
<div class="fast-invite-links mt-3">
<div class="title is-6 mb-2">Generated fast join links</div>
<template v-if="fastInviteLinks.length > 0">
<div class="fast-invite-link" v-for="link in fastInviteLinks" :key="link.name">
<div class="fast-invite-recipient">
<strong>{{ link.email || link.name }}</strong>
<span v-if="link.email">{{ link.name }}</span>
</div>
<code class="select-all">{{ link.href }}</code>
<button class="button invitee-list-action" @click="copyFastInviteLink(link)" title="Copy invite link">
<FontAwesomeIcon :icon="faCopy" />
</button>
<div class="fast-invite-links mt-3">
<div class="title is-6 mb-2">Generated invite links</div>
<template v-if="fastInviteLinks.length > 0">
<div class="fast-invite-link" v-for="link in fastInviteLinks" :key="link.name">
<div class="fast-invite-recipient">
<strong>{{ link.label || link.email || link.name }}</strong>
<span v-if="link.label || link.email">{{ link.name }}</span>
</div>
</template>
<p v-else class="invite-link-empty">No generated links yet.</p>
</div>
<code class="select-all">{{ link.href }}</code>
<button class="button invitee-list-action" @click="copyFastInviteLink(link)" title="Copy invite link">
<FontAwesomeIcon :icon="faCopy" />
</button>
</div>
</template>
<p v-else class="invite-link-empty">No generated links yet.</p>
</div>

<template v-if="canManageMembers">
<p class="mt-2">Enter an email address or NDN name below</p>

<div class="field has-addons mt-2">
Expand Down Expand Up @@ -214,9 +216,10 @@ const invitees = ref([] as IProfile[]);
const pendingInvitees = ref([] as IProfile[]);
const pendingRequests = ref([] as IProfile[]);
const removingMember = ref<string | null>(null);
const fastInviteLinks = ref([] as { name: string; email?: string; href: string }[]);

type FastInviteLink = { name: string; email?: string; label?: string; href: string };
const fastInviteLinks = ref([] as FastInviteLink[]);
const MAX_BATCH = 100;
const OWNER_JOIN_LINK_LABEL = 'Owner join link';

const allInvitees = computed(() => {
return [
Expand Down Expand Up @@ -260,9 +263,25 @@ watch(
})
inviteLink.value = await wksp.value.invite.getJoinLink(router);
members.value = await wksp.value.getMembers();
await refreshOwnerJoinLink();
},
);

async function refreshOwnerJoinLink() {
if (!wksp.value) return;

const href = await wksp.value.invite.getJoinLink(router);
const link = {
name: wksp.value.metadata.name,
label: OWNER_JOIN_LINK_LABEL,
href,
};
fastInviteLinks.value = [
link,
...fastInviteLinks.value.filter((existing) => existing.label !== OWNER_JOIN_LINK_LABEL),
];
}

// Copy invitee list (including pending ones) to clipboard
// Use comma as delimiters
async function copyInviteeList() {
Expand All @@ -278,9 +297,9 @@ async function copyInviteeList() {
Toast.success(`Copied ${allInvitees.value.length} users to clipboard!`);
}

async function copyFastInviteLink(link: { name: string; email?: string; href: string }) {
async function copyFastInviteLink(link: FastInviteLink) {
await navigator.clipboard.writeText(link.href);
Toast.success(`Copied invite link for ${link.email || link.name}`);
Toast.success(`Copied invite link for ${link.label || link.email || link.name}`);
}

async function copyInviteLink() {
Expand Down Expand Up @@ -506,7 +525,7 @@ async function send() {
return;
}

const generatedLinks: { name: string; email?: string; href: string }[] = [];
const generatedLinks: FastInviteLink[] = [];

// Publish invitations and create per-invitee fast join links.
for (const invitee of pendingInvitees.value) {
Expand All @@ -520,7 +539,10 @@ async function send() {
}
}

fastInviteLinks.value = generatedLinks;
fastInviteLinks.value = [
...fastInviteLinks.value.filter((link) => link.label === OWNER_JOIN_LINK_LABEL),
...generatedLinks,
];
pendingInvitees.value = [];

try {
Expand Down
73 changes: 69 additions & 4 deletions src/components/NavBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@
v-if="!isMasterOwnerDevice(device)"
class="owner-device-action"
type="button"
:disabled="transferringMasterDeviceId === device.deviceId || !canCurrentWorkspaceManageMls()"
:disabled="transferringMasterDeviceId === device.deviceId || !canTransferMasterToDevice(device)"
@click="transferMasterToDevice(device)"
>
{{
Expand All @@ -197,12 +197,25 @@
: 'Make Master'
}}
</button>
<button
v-if="!isLocalOwnerDevice(device)"
class="owner-device-action danger"
type="button"
:disabled="removingOwnerDeviceId === device.deviceId || !canRemoveOwnerDevice(device)"
@click="removeOwnerDevice(device)"
>
{{
removingOwnerDeviceId === device.deviceId
? 'Removing...'
: 'Remove'
}}
</button>
</div>
</div>
</div>
<div v-else class="owner-device-empty">No owner devices registered yet.</div>
<div v-if="!canCurrentWorkspaceManageMls()" class="owner-device-note">
Only the current master owner device can transfer control.
The current master can transfer control to any owner device. This owner device can also make itself master after it has joined MLS.
</div>
</div>
</li>
Expand Down Expand Up @@ -297,6 +310,7 @@ const currentWorkspaceIsOwner = ref(false);
const localOwnerDeviceId = ref(null as string | null);
const masterOwnerDeviceId = ref(null as string | null);
const transferringMasterDeviceId = ref(null as string | null);
const removingOwnerDeviceId = ref(null as string | null);

// vue-tsc chokes on this type inference
const projectTree = useTemplateRef<Array<InstanceType<typeof ProjectTree>>>('projectTree');
Expand Down Expand Up @@ -565,6 +579,21 @@ function isLocalOwnerDevice(device: IOwnerDeviceRecord): boolean {
return device.deviceId === localOwnerDeviceId.value;
}

function canTransferMasterToDevice(device: IOwnerDeviceRecord): boolean {
const wksp = globalThis.ActiveWorkspace;
if (!wksp?.metadata.owner) return false;
if (wksp.invite.isMasterDevice()) return true;
return isLocalOwnerDevice(device) && wksp.invite.hasMlsGroup();
}

function canRemoveOwnerDevice(device: IOwnerDeviceRecord): boolean {
const wksp = globalThis.ActiveWorkspace;
return !!wksp?.metadata.owner &&
wksp.invite.isMasterDevice() &&
!isLocalOwnerDevice(device) &&
!isMasterOwnerDevice(device);
}

async function renameOwnerDevice(device: IOwnerDeviceRecord) {
const wksp = globalThis.ActiveWorkspace;
if (!wksp?.metadata.owner) {
Expand All @@ -591,8 +620,8 @@ async function transferMasterToDevice(device: IOwnerDeviceRecord) {
Toast.error('No active workspace');
return;
}
if (!canCurrentWorkspaceManageMls()) {
Toast.error('Only the current master owner device can transfer control');
if (!canTransferMasterToDevice(device)) {
Toast.error('This owner device can only make itself master');
return;
}
if (transferringMasterDeviceId.value) return;
Expand All @@ -613,6 +642,34 @@ async function transferMasterToDevice(device: IOwnerDeviceRecord) {
}
}

async function removeOwnerDevice(device: IOwnerDeviceRecord) {
const wksp = globalThis.ActiveWorkspace;
if (!wksp) {
Toast.error('No active workspace');
return;
}
if (!canRemoveOwnerDevice(device)) {
Toast.error('Only the master owner device can remove another non-master owner device');
return;
}
if (removingOwnerDeviceId.value) return;
if (!globalThis.confirm(`Remove owner device ${device.label} from MLS and the registry?`)) {
return;
}

removingOwnerDeviceId.value = device.deviceId;
const progress = Toast.loading(`Removing owner device ${device.label}...`);
try {
await wksp.invite.removeOwnerDevice(device.deviceId);
syncOwnerDevices();
await progress.success(`Removed owner device ${device.label}`);
} catch (err) {
await progress.error(`Failed to remove owner device: ${err}`);
} finally {
removingOwnerDeviceId.value = null;
}
}

async function sosRequest() {
if (isRequestingSOS.value) return;

Expand Down Expand Up @@ -880,6 +937,14 @@ async function resetMlsState() {
cursor: default;
opacity: 0.55;
}

&.danger {
background: rgba(255, 88, 88, 0.22);

&:hover:enabled {
background: rgba(255, 88, 88, 0.34);
}
}
}

.owner-device-empty,
Expand Down
11 changes: 11 additions & 0 deletions src/services/openmls-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ type WasmGroup = {
merge_pending_commit(): void;
apply_commit(commitBytes: Uint8Array): void;
my_index(): number;
member_indexes_by_identity(identityValue: Uint8Array): Uint32Array;
member_indexes_by_identity_prefix(identityPrefix: Uint8Array): Uint32Array;
member_identities(): unknown;
group_id_bytes(): Uint8Array;
epoch(): bigint;
export_secret(label: string, context: Uint8Array, len: number): Uint8Array;
Expand Down Expand Up @@ -141,6 +143,15 @@ export class OpenMlsLiteGroup {
return Array.from(this.inner.member_indexes_by_identity_prefix(identityPrefix));
}

memberIndexesByIdentity(identityValue: Uint8Array): number[] {
return Array.from(this.inner.member_indexes_by_identity(identityValue));
}

memberIdentities(): string[] {
return Array.from(this.inner.member_identities() as Iterable<unknown>)
.filter((identity): identity is string => typeof identity === 'string');
}

exportSecret(label: string, context = new Uint8Array(), len = 32): Uint8Array {
return this.inner.export_secret(label, context, len);
}
Expand Down
4 changes: 4 additions & 0 deletions src/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export type IWkspStats = {
mlsJoinAttempts?: number;
mlsOwnerBootstrapped?: boolean;
mlsKeys?: IMlsKey[];
/** Member selected to help recover an owner device into MLS */
ownerRecoveryHelper?: string;
/** Time when this owner device requested member-assisted MLS recovery */
ownerRecoveryRequestedAt?: number;
};

export type IOwnerDeviceRecord = {
Expand Down
Loading
Loading