DRY pass #2, batch 8, F17: a person and an account have one shape each (#5372)

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>
This commit is contained in:
2026-10-08 15:11:29 -04:00
co-authored by Claude Opus 5.5
parent 0d82e26eea
commit 8c45725a2a
5 changed files with 25 additions and 43 deletions
+4 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { useSessionStore } from "../stores/session";
import { useSessionStore, type User } from "../stores/session";
import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue";
import { useRowAction, useLoad } from "../composables/useAction";
@@ -10,24 +10,17 @@ import { useRowAction, useLoad } from "../composables/useAction";
// (#5173). The admin hands the link over; a person can also mail one to themselves
// when the server can send email (#5266).
interface Account {
id: string;
email: string;
display_name: string;
is_admin: boolean;
}
const session = useSessionStore();
const accounts = ref<Account[]>([]);
const accounts = ref<User[]>([]);
const { loading, error, load } = useLoad(async () => {
accounts.value = (await api.get<{ accounts: Account[] }>("/api/accounts")).accounts;
accounts.value = (await api.get<{ accounts: User[] }>("/api/accounts")).accounts;
}, "Couldn't load accounts.");
const { busy, act } = useRowAction();
// The reset link just made, and whose it is. Never retrievable again once dismissed.
const fresh = ref<{ link: string; email: string } | null>(null);
async function resetLink(account: Account) {
async function resetLink(account: User) {
const self = account.id === session.user?.id;
const warning = self
? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included."
+6 -11
View File
@@ -1,6 +1,7 @@
<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";
@@ -11,24 +12,18 @@ import { useRowAction, useLoad } from "../composables/useAction";
// 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[];
members: Member[];
}
const groups = ref<Group[]>([]);
const accounts = ref<Person[]>([]);
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: Person[] }>("/api/accounts"),
api.get<{ accounts: Member[] }>("/api/accounts"),
]);
groups.value = g.groups;
accounts.value = a.accounts;
@@ -41,7 +36,7 @@ const picks = ref<Record<string, string>>({});
const byName = (a: Group, b: Group) => a.name.localeCompare(b.name);
function outside(group: Group): Person[] {
function outside(group: Group): Member[] {
const inside = new Set(group.members.map((m) => m.id));
return accounts.value.filter((a) => !inside.has(a.id));
}
@@ -102,7 +97,7 @@ function addMember(group: Group) {
);
}
function removeMember(group: Group, person: Person) {
function removeMember(group: Group, person: Member) {
void act(
group.id,
async () => replace(await api.del<Group>(`/api/groups/${group.id}/members/${person.id}`)),
+2 -7
View File
@@ -18,6 +18,7 @@ from .models.user import User
from .password_resets import issue
from .proxy import client_address
from .responses import not_found, parse_uuid
from .serialize import serialize_user
bp = Blueprint("accounts", __name__, url_prefix="/api/accounts")
@@ -26,13 +27,7 @@ logger = logging.getLogger(__name__)
def _serialize_account(user: User) -> dict:
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
"created_at": iso(user.created_at),
}
return {**serialize_user(user), "created_at": iso(user.created_at)}
@bp.get("")
+6 -14
View File
@@ -19,6 +19,7 @@ from .models.device_token import DeviceToken
from .models.user import User
from .proxy import client_address
from .responses import json_error, not_found, parse_uuid, too_many
from .serialize import serialize_user
from .ratelimit import (
register_by_address,
reset_mail_by_account,
@@ -63,15 +64,6 @@ SETUP_CLOSED = (
DEVICE_NAME_CAP = 100
def _serialize_user(user: User) -> dict:
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
}
def _sign_in(user: User) -> None:
session[SESSION_KEY] = str(user.id)
session[EPOCH_KEY] = user.session_epoch
@@ -313,7 +305,7 @@ async def register():
)
detail = "first account, admin" if is_first else ("by invite" if invite_id else None)
await audit.record(audit.REGISTERED, user_id=user.id, email=email, detail=detail)
return jsonify(_serialize_user(user)), 201
return jsonify(serialize_user(user)), 201
async def _check_credentials(db, email: str, password: str, route: str) -> User | None:
@@ -362,7 +354,7 @@ async def login():
_sign_in(user)
logger.info("sign-in ok email=%s from=%s", email, client_address())
await audit.record(audit.SIGN_IN, user_id=user.id, email=email)
return jsonify(_serialize_user(user))
return jsonify(serialize_user(user))
@bp.post("/logout")
@@ -379,7 +371,7 @@ async def me():
if user is None:
_sign_out()
return _unauthenticated()
return jsonify(_serialize_user(user))
return jsonify(serialize_user(user))
@bp.get("/storage")
@@ -469,7 +461,7 @@ async def reset_password():
"password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()
)
await audit.record(audit.PASSWORD_RESET, user_id=user.id, email=user.email, detail=_unlinked(unlinked))
return jsonify(_serialize_user(user))
return jsonify(serialize_user(user))
async def _sign_out_elsewhere(db, user: User, keep_token: str | None) -> int:
@@ -619,7 +611,7 @@ async def device_login():
)
await db.commit()
await audit.record(audit.DEVICE_LINKED, user_id=user.id, email=email, detail=row.name)
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
return jsonify({"token": token, "device": _serialize_device(row), "user": serialize_user(user)}), 201
@bp.post("/devices")
+7
View File
@@ -32,3 +32,10 @@ def serialize_person(user: User) -> dict:
choosing them takes and all a fellow member needs to know. The share directory,
a share's recipient and a group's members all show people this way."""
return {"id": str(user.id), "display_name": user.display_name, "email": user.email}
def serialize_user(user: User) -> dict:
"""An account as its owner sees it: the person plus whether they are an admin.
Sign-in, /me and device login answer with this; the admin's account list adds
when each was made."""
return {**serialize_person(user), "is_admin": user.is_admin}