web, core, desktop: share a note with a group, and Settings → Groups
The Share dialog lists people and groups in one picker and shows a group share as its name and member count. Settings gains a Groups section for the admin: create, rename, delete, and add or remove people. The core client reads the directory's groups and group shares (ShareTarget: a member or a group); the desktop command takes user_id or group_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
|
||||
import type { TitleEntry } from "../stores/titles";
|
||||
import type { User } from "../stores/session";
|
||||
import type { PublicConfig } from "../stores/config";
|
||||
import type { DeviceToken, ImportResult, Member, NoteShare, Repo } from "./repo";
|
||||
import type { DeviceToken, Directory, ImportResult, NoteShare, Repo } from "./repo";
|
||||
|
||||
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
|
||||
|
||||
@@ -95,9 +95,15 @@ export const local: Repo = {
|
||||
// Sharing is between accounts on a server, so these go to the linked one (#5175).
|
||||
// Unlinked, each rejects with the core's explanation, which the dialog shows.
|
||||
shares: {
|
||||
directory: () => invoke<Member[]>("shares_directory"),
|
||||
directory: () => invoke<Directory>("shares_directory"),
|
||||
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
|
||||
share: (noteId, userId, permission) => invoke<NoteShare[]>("shares_share", { noteId, userId, permission }),
|
||||
share: (noteId, target, permission) =>
|
||||
invoke<NoteShare[]>("shares_share", {
|
||||
noteId,
|
||||
userId: "user_id" in target ? target.user_id : null,
|
||||
groupId: "group_id" in target ? target.group_id : null,
|
||||
permission,
|
||||
}),
|
||||
unshare: (noteId, shareId) => invoke<NoteShare[]>("shares_unshare", { noteId, shareId }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,11 +55,29 @@ export interface Member {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** A group the admin made, as the directory shows it (#5177). */
|
||||
export interface ShareGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
/** Everyone a note can be shared with: the other people, and every group. */
|
||||
export interface Directory {
|
||||
members: Member[];
|
||||
groups: ShareGroup[];
|
||||
}
|
||||
|
||||
/** Who a share goes to: one person, or a group (whoever is in it at the time). */
|
||||
export type ShareTarget = { user_id: string } | { group_id: string };
|
||||
|
||||
export type SharePermission = "view" | "edit";
|
||||
|
||||
/** One share of a note: exactly one of `member` and `group` is set. */
|
||||
export interface NoteShare {
|
||||
id: string;
|
||||
member: Member;
|
||||
member: Member | null;
|
||||
group: ShareGroup | null;
|
||||
permission: SharePermission;
|
||||
created_at: string | null;
|
||||
}
|
||||
@@ -135,11 +153,11 @@ export interface NotesRepo {
|
||||
}
|
||||
|
||||
export interface SharesRepo {
|
||||
/** Everyone on the instance but you. */
|
||||
directory(): Promise<Member[]>;
|
||||
/** Everyone on the instance but you, and every group. */
|
||||
directory(): Promise<Directory>;
|
||||
list(noteId: string): Promise<NoteShare[]>;
|
||||
/** Share with one member, or change their permission. Answers the note's shares. */
|
||||
share(noteId: string, userId: string, permission: SharePermission): Promise<NoteShare[]>;
|
||||
/** Share with a person or a group, or change their permission. Answers the note's shares. */
|
||||
share(noteId: string, target: ShareTarget, permission: SharePermission): Promise<NoteShare[]>;
|
||||
unshare(noteId: string, shareId: string): Promise<NoteShare[]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import type { User } from "../stores/session";
|
||||
import type { PublicConfig } from "../stores/config";
|
||||
import type {
|
||||
DeviceToken,
|
||||
Directory,
|
||||
ImportResult,
|
||||
Member,
|
||||
NoteChanges,
|
||||
NoteShare,
|
||||
NoteCreateInput,
|
||||
@@ -120,10 +120,10 @@ export const rest: Repo = {
|
||||
},
|
||||
|
||||
shares: {
|
||||
directory: async () => (await api.get<{ members: Member[] }>("/api/users/directory")).members,
|
||||
directory: () => api.get<Directory>("/api/users/directory"),
|
||||
list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares,
|
||||
share: async (noteId, userId, permission) =>
|
||||
(await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { user_id: userId, permission })).shares,
|
||||
share: async (noteId, target, permission) =>
|
||||
(await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { ...target, permission })).shares,
|
||||
unshare: async (noteId, shareId) =>
|
||||
(await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import BaseInput from "./BaseInput.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
// Admin: groups of people a note can be shared with in one go (#5177). A group is
|
||||
// the instance's; anyone can share with it, and a note shared with it reaches whoever
|
||||
// is in it at the time, so adding or removing someone here changes what they see.
|
||||
|
||||
interface Person {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
members: Person[];
|
||||
}
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
const groups = ref<Group[]>([]);
|
||||
const accounts = ref<Person[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const name = ref("");
|
||||
const creating = ref(false);
|
||||
const busy = ref("");
|
||||
// Which group's "Add someone" picker holds which account id.
|
||||
const picks = ref<Record<string, string>>({});
|
||||
|
||||
const byName = (a: Group, b: Group) => a.name.localeCompare(b.name);
|
||||
|
||||
function outside(group: Group): Person[] {
|
||||
const inside = new Set(group.members.map((m) => m.id));
|
||||
return accounts.value.filter((a) => !inside.has(a.id));
|
||||
}
|
||||
|
||||
const empty = computed(() => !loading.value && !groups.value.length);
|
||||
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
const [g, a] = await Promise.all([
|
||||
api.get<{ groups: Group[] }>("/api/groups"),
|
||||
api.get<{ accounts: Person[] }>("/api/accounts"),
|
||||
]);
|
||||
groups.value = g.groups;
|
||||
accounts.value = a.accounts;
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, "Couldn't load groups.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(updated: Group) {
|
||||
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
|
||||
}
|
||||
|
||||
async function act(id: string, fn: () => Promise<void>, fallback: string) {
|
||||
busy.value = id;
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, fallback));
|
||||
} finally {
|
||||
busy.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!name.value.trim()) return;
|
||||
creating.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const made = await api.post<Group>("/api/groups", { name: name.value.trim() });
|
||||
groups.value = [...groups.value, made].sort(byName);
|
||||
name.value = "";
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, "Couldn't create the group.");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function rename(group: Group) {
|
||||
const next = window.prompt("Rename the group", group.name)?.trim();
|
||||
if (!next || next === group.name) return;
|
||||
void act(
|
||||
group.id,
|
||||
async () => replace(await api.patch<Group>(`/api/groups/${group.id}`, { name: next })),
|
||||
"Couldn't rename it.",
|
||||
);
|
||||
}
|
||||
|
||||
function remove(group: Group) {
|
||||
if (!window.confirm(`Delete "${group.name}"? Notes shared with it stop being shared with its members.`)) return;
|
||||
void act(
|
||||
group.id,
|
||||
async () => {
|
||||
await api.del(`/api/groups/${group.id}`);
|
||||
groups.value = groups.value.filter((g) => g.id !== group.id);
|
||||
},
|
||||
"Couldn't delete it.",
|
||||
);
|
||||
}
|
||||
|
||||
function addMember(group: Group) {
|
||||
const userId = picks.value[group.id];
|
||||
if (!userId) return;
|
||||
void act(
|
||||
group.id,
|
||||
async () => {
|
||||
replace(await api.post<Group>(`/api/groups/${group.id}/members`, { user_id: userId }));
|
||||
picks.value[group.id] = "";
|
||||
},
|
||||
"Couldn't add them.",
|
||||
);
|
||||
}
|
||||
|
||||
function removeMember(group: Group, person: Person) {
|
||||
void act(
|
||||
group.id,
|
||||
async () => replace(await api.del<Group>(`/api/groups/${group.id}/members/${person.id}`)),
|
||||
"Couldn't remove them.",
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-5">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Groups</h2>
|
||||
|
||||
<form class="flex flex-wrap items-end gap-3" @submit.prevent="create">
|
||||
<BaseInput id="group-name" v-model="name" label="New group" placeholder="Family" class="min-w-48 flex-1" />
|
||||
<BaseButton type="submit" :loading="creating" :disabled="!name.trim()">Create group</BaseButton>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">Loading…</div>
|
||||
<p v-else-if="empty" class="text-sm text-neutral-400">No groups yet.</p>
|
||||
<ul v-else class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="g in groups"
|
||||
:key="g.id"
|
||||
class="flex flex-col gap-3 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ g.name }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ g.members.length }} {{ g.members.length === 1 ? "person" : "people" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
:disabled="busy === g.id"
|
||||
@click="rename(g)"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||
:disabled="busy === g.id"
|
||||
@click="remove(g)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul v-if="g.members.length" class="flex flex-wrap gap-1.5">
|
||||
<li
|
||||
v-for="m in g.members"
|
||||
:key="m.id"
|
||||
class="flex items-center gap-1 rounded-full bg-neutral-100 py-0.5 pl-2.5 pr-1 text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
|
||||
:title="m.email"
|
||||
>
|
||||
{{ m.display_name || m.email }}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full p-0.5 text-neutral-400 hover:text-red-500 disabled:opacity-50"
|
||||
:aria-label="`Remove ${m.display_name || m.email} from ${g.name}`"
|
||||
:disabled="busy === g.id"
|
||||
@click="removeMember(g, m)"
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form v-if="outside(g).length" class="flex flex-wrap items-center gap-2" @submit.prevent="addMember(g)">
|
||||
<select
|
||||
v-model="picks[g.id]"
|
||||
class="min-w-0 flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
|
||||
:aria-label="`Add someone to ${g.name}`"
|
||||
:disabled="busy === g.id"
|
||||
>
|
||||
<option :value="undefined" disabled>Add someone…</option>
|
||||
<option v-for="a in outside(g)" :key="a.id" :value="a.id">{{ a.display_name }} ({{ a.email }})</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md border border-neutral-300 px-2.5 py-1.5 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
:disabled="busy === g.id || !picks[g.id]"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import type { Member, NoteShare, SharePermission } from "../adapters/repo";
|
||||
import type { Directory, NoteShare, SharePermission, ShareTarget } from "../adapters/repo";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
// Share a note with other people on this instance (#5174). The owner picks a member
|
||||
// from the directory and says whether they may only read it or also change its text.
|
||||
// Only the owner opens this; the server refuses everyone else regardless.
|
||||
// Share a note with other people on this instance (#5174). The owner picks a member,
|
||||
// or a group the admin made (#5177), and says whether they may only read it or also
|
||||
// change its text. Only the owner opens this; the server refuses everyone else
|
||||
// regardless.
|
||||
|
||||
const props = defineProps<{ noteId: string }>();
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
@@ -20,26 +21,47 @@ const PERMISSIONS: { value: SharePermission; label: string }[] = [
|
||||
{ value: "edit", label: "Can edit" },
|
||||
];
|
||||
|
||||
const members = ref<Member[]>([]);
|
||||
const directory = ref<Directory>({ members: [], groups: [] });
|
||||
const shares = ref<NoteShare[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
// "u:<id>" for a person, "g:<id>" for a group: one picker lists both.
|
||||
const pick = ref("");
|
||||
const permission = ref<SharePermission>("view");
|
||||
|
||||
/** The people it isn't shared with yet. */
|
||||
const available = computed(() => {
|
||||
const taken = new Set(shares.value.map((s) => s.member.id));
|
||||
return members.value.filter((m) => !taken.has(m.id));
|
||||
/** The people and groups it isn't shared with yet. */
|
||||
const availablePeople = computed(() => {
|
||||
const taken = new Set(shares.value.map((s) => s.member?.id));
|
||||
return directory.value.members.filter((m) => !taken.has(m.id));
|
||||
});
|
||||
const availableGroups = computed(() => {
|
||||
const taken = new Set(shares.value.map((s) => s.group?.id));
|
||||
return directory.value.groups.filter((g) => !taken.has(g.id));
|
||||
});
|
||||
const anyone = computed(() => directory.value.members.length + directory.value.groups.length > 0);
|
||||
const anyoneLeft = computed(() => availablePeople.value.length + availableGroups.value.length > 0);
|
||||
|
||||
function targetOf(share: NoteShare): ShareTarget {
|
||||
return share.group ? { group_id: share.group.id } : { user_id: share.member?.id ?? "" };
|
||||
}
|
||||
|
||||
function nameOf(share: NoteShare): string {
|
||||
return share.group?.name ?? share.member?.display_name ?? "";
|
||||
}
|
||||
|
||||
function detailOf(share: NoteShare): string {
|
||||
if (!share.group) return share.member?.email ?? "";
|
||||
const n = share.group.member_count;
|
||||
return `Group · ${n} ${n === 1 ? "person" : "people"}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
[members.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
|
||||
[directory.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
|
||||
} catch (e) {
|
||||
loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
|
||||
} finally {
|
||||
@@ -62,13 +84,14 @@ async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
|
||||
|
||||
async function add() {
|
||||
if (!pick.value) return;
|
||||
const userId = pick.value;
|
||||
await run(() => repo.shares.share(props.noteId, userId, permission.value), "Couldn't share the note.");
|
||||
const id = pick.value.slice(2);
|
||||
const target: ShareTarget = pick.value.startsWith("g:") ? { group_id: id } : { user_id: id };
|
||||
await run(() => repo.shares.share(props.noteId, target, permission.value), "Couldn't share the note.");
|
||||
if (!error.value) pick.value = "";
|
||||
}
|
||||
|
||||
function change(share: NoteShare, next: SharePermission) {
|
||||
void run(() => repo.shares.share(props.noteId, share.member.id, next), "Couldn't change that.");
|
||||
void run(() => repo.shares.share(props.noteId, targetOf(share), next), "Couldn't change that.");
|
||||
}
|
||||
|
||||
function remove(share: NoteShare) {
|
||||
@@ -103,14 +126,23 @@ onMounted(load);
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="!members.length" class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p v-if="!anyone" class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No one else has an account here yet. An admin can invite people from Settings.
|
||||
</p>
|
||||
|
||||
<form v-else-if="available.length" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
|
||||
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person" :disabled="busy">
|
||||
<form v-else-if="anyoneLeft" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
|
||||
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person or group" :disabled="busy">
|
||||
<option value="" disabled>Choose someone…</option>
|
||||
<option v-for="m in available" :key="m.id" :value="m.id">{{ m.display_name }} ({{ m.email }})</option>
|
||||
<optgroup v-if="availablePeople.length" label="People">
|
||||
<option v-for="m in availablePeople" :key="m.id" :value="`u:${m.id}`">
|
||||
{{ m.display_name }} ({{ m.email }})
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="availableGroups.length" label="Groups">
|
||||
<option v-for="g in availableGroups" :key="g.id" :value="`g:${g.id}`">
|
||||
{{ g.name }} ({{ g.member_count }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy">
|
||||
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||
@@ -132,13 +164,13 @@ onMounted(load);
|
||||
<ul v-else class="flex flex-col gap-1">
|
||||
<li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ s.member.display_name }}</p>
|
||||
<p class="truncate text-xs text-neutral-400">{{ s.member.email }}</p>
|
||||
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ nameOf(s) }}</p>
|
||||
<p class="truncate text-xs text-neutral-400">{{ detailOf(s) }}</p>
|
||||
</div>
|
||||
<select
|
||||
:value="s.permission"
|
||||
:class="selectClass"
|
||||
:aria-label="`Permission for ${s.member.display_name}`"
|
||||
:aria-label="`Permission for ${nameOf(s)}`"
|
||||
:disabled="busy"
|
||||
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
|
||||
>
|
||||
@@ -147,8 +179,8 @@ onMounted(load);
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="`Stop sharing with ${s.member.display_name}`"
|
||||
:aria-label="`Stop sharing with ${s.member.display_name}`"
|
||||
:title="`Stop sharing with ${nameOf(s)}`"
|
||||
:aria-label="`Stop sharing with ${nameOf(s)}`"
|
||||
:disabled="busy"
|
||||
@click="remove(s)"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useConfigStore } from "../stores/config";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import InviteList from "../components/InviteList.vue";
|
||||
import AccountList from "../components/AccountList.vue";
|
||||
import GroupList from "../components/GroupList.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
@@ -227,9 +228,10 @@ onMounted(load);
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Outside the settings form: each invite and account action saves on its own,
|
||||
<!-- Outside the settings form: each invite, account and group action saves on its own,
|
||||
and the form's Save button has nothing to do with them. -->
|
||||
<InviteList v-if="items.length" class="mt-10" />
|
||||
<AccountList v-if="items.length" class="mt-10" />
|
||||
<GroupList v-if="items.length" class="mt-10" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user