Web: GroupList's Person was adapters/repo's Member field for field, and AccountList's Account was the session store's User; both now import them. The bridge's Identity stays, as the Tauri mirror of the core's struct. Server: auth._serialize_user and accounts' _serialize_account wrote the same four fields. serialize.serialize_user is serialize_person plus is_admin, and the account list adds created_at to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
6.6 KiB
Vue
201 lines
6.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { api } from "../api/client";
|
|
import type { Member } from "../adapters/repo";
|
|
import { errorMessage } from "../api/errors";
|
|
import BaseButton from "./BaseButton.vue";
|
|
import BaseInput from "./BaseInput.vue";
|
|
import Icon from "./Icon.vue";
|
|
import { useRowAction, useLoad } from "../composables/useAction";
|
|
|
|
// 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 Group {
|
|
id: string;
|
|
name: string;
|
|
members: Member[];
|
|
}
|
|
|
|
const groups = ref<Group[]>([]);
|
|
const accounts = ref<Member[]>([]);
|
|
const { loading, error, load } = useLoad(async () => {
|
|
const [g, a] = await Promise.all([
|
|
api.get<{ groups: Group[] }>("/api/groups"),
|
|
api.get<{ accounts: Member[] }>("/api/accounts"),
|
|
]);
|
|
groups.value = g.groups;
|
|
accounts.value = a.accounts;
|
|
}, "Couldn't load groups.");
|
|
const name = ref("");
|
|
const creating = ref(false);
|
|
const { busy, act } = useRowAction();
|
|
// 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): Member[] {
|
|
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);
|
|
|
|
function replace(updated: Group) {
|
|
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
|
|
}
|
|
|
|
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: Member) {
|
|
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="section-label">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="form-error">{{ error }}</p>
|
|
|
|
<div v-if="loading" class="list-empty">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="btn-sm"
|
|
:disabled="busy === g.id"
|
|
@click="rename(g)"
|
|
>
|
|
Rename
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn-sm-danger"
|
|
: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="btn-sm py-1.5"
|
|
:disabled="busy === g.id || !picks[g.id]"
|
|
>
|
|
Add
|
|
</button>
|
|
</form>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</template>
|