multiple groups per user
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
userCreateSchema,
|
||||
userPatchSchema,
|
||||
type Group,
|
||||
type GroupMembership,
|
||||
type MeResponse,
|
||||
} from '@cardy/shared';
|
||||
import type { CardsService } from '../cards.js';
|
||||
@@ -16,7 +17,7 @@ import { ADMIN_API_PREFIX } from '../config.js';
|
||||
import { forwardedPrefix, publicBaseUrl } from '../forwarded.js';
|
||||
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
|
||||
import { parseBody, readMultipart, sendImage } from '../http.js';
|
||||
import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js';
|
||||
import { requireGroupAdmin, requireSuperadmin, roleIn, type StoredUser, type UsersService } from '../users.js';
|
||||
|
||||
export const GROUP_COOKIE = 'cardy_group';
|
||||
export const CSRF_HEADER = 'x-cardy-admin';
|
||||
@@ -24,7 +25,8 @@ export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
|
||||
|
||||
export interface AdminContext {
|
||||
user: StoredUser;
|
||||
ownGroup: Group;
|
||||
/** The user's groups with their role in each. */
|
||||
memberships: GroupMembership[];
|
||||
currentGroup: Group;
|
||||
}
|
||||
|
||||
@@ -75,16 +77,22 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
|
||||
if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy');
|
||||
const user = await users.findUser(email);
|
||||
if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`);
|
||||
const ownGroup = await users.getGroup(user.groupId);
|
||||
if (!ownGroup) throw unauthorized('User group does not exist');
|
||||
let currentGroup = ownGroup;
|
||||
const cookieGroupId = request.cookies[GROUP_COOKIE];
|
||||
if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) {
|
||||
currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup;
|
||||
}
|
||||
request.admin = { user, ownGroup, currentGroup };
|
||||
const memberships = await users.membershipsOf(user);
|
||||
if (!memberships.length) throw unauthorized('None of your groups exists');
|
||||
request.admin = { user, memberships, currentGroup: await resolveCurrentGroup(user, memberships, request.cookies[GROUP_COOKIE]) };
|
||||
});
|
||||
|
||||
/** The group from the cookie if the user may use it (member, or any group for superadmins), else the first own group. */
|
||||
async function resolveCurrentGroup(user: StoredUser, memberships: GroupMembership[], cookieGroupId: string | undefined): Promise<Group> {
|
||||
const first = memberships[0]!;
|
||||
const fallback = { id: first.id, name: first.name };
|
||||
if (!cookieGroupId) return fallback;
|
||||
const own = memberships.find((m) => m.id === cookieGroupId);
|
||||
if (own) return { id: own.id, name: own.name };
|
||||
if (user.superadmin) return (await users.getGroup(cookieGroupId)) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Cookie path as the browser sees it, e.g. /api/admin.
|
||||
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
|
||||
reply.setCookie(GROUP_COOKIE, groupId, {
|
||||
@@ -104,9 +112,15 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
|
||||
// ---- current user ----
|
||||
|
||||
app.get('/me', async (request, reply): Promise<MeResponse> => {
|
||||
const { user, ownGroup, currentGroup } = request.admin;
|
||||
const { user, memberships, currentGroup } = request.admin;
|
||||
if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id);
|
||||
return { email: user.email, role: user.role, group: ownGroup, currentGroup };
|
||||
return {
|
||||
email: user.email,
|
||||
superadmin: user.superadmin,
|
||||
role: roleIn(user, currentGroup.id) ?? 'user',
|
||||
groups: memberships,
|
||||
currentGroup,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
|
||||
@@ -134,38 +148,42 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
/** Any user may switch between their groups; superadmins between all groups. */
|
||||
app.put('/current-group', async (request, reply) => {
|
||||
requireSuperadmin(request.admin.user);
|
||||
const { user, memberships } = request.admin;
|
||||
const { groupId } = parseBody(currentGroupSchema, request.body);
|
||||
const group = await users.getGroup(groupId);
|
||||
if (!group) throw notFound('Group not found');
|
||||
if (!user.superadmin && !memberships.some((m) => m.id === group.id)) throw forbidden('You are not a member of this group');
|
||||
setGroupCookie(request, reply, group.id);
|
||||
return group;
|
||||
});
|
||||
|
||||
// ---- users of the current group ----
|
||||
// ---- members of the current group ----
|
||||
|
||||
app.get('/users', async (request) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request)));
|
||||
const members = await users.listMembers(user, groupId);
|
||||
return Promise.all(members.map((u) => users.userView(u, groupId, apiUrl(request))));
|
||||
});
|
||||
|
||||
/** Adds a member (an existing user from another group, or a new user). */
|
||||
app.post('/users', async (request, reply) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body));
|
||||
return reply.code(201).send(users.userView(created, apiUrl(request)));
|
||||
const added = await users.addMember(user, groupId, parseBody(userCreateSchema, request.body));
|
||||
return reply.code(201).send(await users.userView(added, groupId, apiUrl(request)));
|
||||
});
|
||||
|
||||
app.patch<{ Params: EmailParams }>('/users/:email', async (request) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
|
||||
return users.userView(updated, apiUrl(request));
|
||||
const updated = await users.updateMember(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
|
||||
return users.userView(updated, groupId, apiUrl(request));
|
||||
});
|
||||
|
||||
app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => {
|
||||
/** Removes the member from the current group; a user without other groups is deleted. */
|
||||
app.delete<{ Params: EmailParams }>('/users/:email', async (request) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
await users.deleteUser(user, groupId, request.params.email);
|
||||
return reply.code(204).send();
|
||||
return users.removeMember(user, groupId, request.params.email);
|
||||
});
|
||||
|
||||
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||
import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
|
||||
import type { CardsService } from '../cards.js';
|
||||
import { unauthorized } from '../errors.js';
|
||||
import { notFound, unauthorized } from '../errors.js';
|
||||
import { parseBody, readMultipart, sendImage } from '../http.js';
|
||||
import type { KeyOwner, UsersService } from '../users.js';
|
||||
|
||||
@@ -19,8 +19,7 @@ interface Options {
|
||||
|
||||
const CLIENT_ACCESS = { includeDeleted: false };
|
||||
|
||||
type IdParams = { id: string };
|
||||
type ImageParams = { id: string; imageId: string };
|
||||
type Params = Record<string, string>;
|
||||
|
||||
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
|
||||
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
|
||||
@@ -46,50 +45,64 @@ export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards
|
||||
request.keyOwner = owner;
|
||||
});
|
||||
|
||||
const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id;
|
||||
|
||||
app.get('/auth', async (request): Promise<ApiAuthResponse> => {
|
||||
const { user, group } = request.keyOwner;
|
||||
return { email: user.email, group };
|
||||
const { user, groups } = request.keyOwner;
|
||||
return { email: user.email, groups, group: groups[0]! };
|
||||
});
|
||||
|
||||
app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS));
|
||||
// Cards of a group the key's owner belongs to.
|
||||
cardRoutes(app, cards, '/groups/:groupId', (request) => {
|
||||
const groupId = (request.params as Params).groupId;
|
||||
if (!request.keyOwner.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
|
||||
return groupId!;
|
||||
});
|
||||
// Apps from before multi-group: the owner's first group.
|
||||
cardRoutes(app, cards, '', (request) => request.keyOwner.groups[0]!.id);
|
||||
};
|
||||
|
||||
app.post('/cards', async (request, reply) => {
|
||||
function cardRoutes(app: FastifyInstance, cards: CardsService, prefix: string, groupOf: (request: FastifyRequest) => string) {
|
||||
const param = (request: FastifyRequest, name: string) => (request.params as Params)[name]!;
|
||||
|
||||
app.get(`${prefix}/cards`, async (request) => cards.list(groupOf(request), CLIENT_ACCESS));
|
||||
|
||||
app.post(`${prefix}/cards`, async (request, reply) => {
|
||||
const groupId = groupOf(request);
|
||||
const { fields, files } = await readMultipart(request);
|
||||
const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
|
||||
const card = await cards.create(groupId, parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
|
||||
return reply.code(201).send(card);
|
||||
});
|
||||
|
||||
app.put<{ Params: IdParams }>('/cards/:id', async (request) => {
|
||||
app.put(`${prefix}/cards/:id`, async (request) => {
|
||||
const groupId = groupOf(request);
|
||||
const { fields, files } = await readMultipart(request);
|
||||
const { removeLogo, ...cardFields } = fields;
|
||||
const patch = parseBody(cardFieldsSchema.partial(), cardFields);
|
||||
const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined);
|
||||
return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo);
|
||||
return cards.update(groupId, param(request, 'id'), patch, CLIENT_ACCESS, logo);
|
||||
});
|
||||
|
||||
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
|
||||
const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS);
|
||||
await cards.setDeleted(groupId(request), card.id, true);
|
||||
app.delete(`${prefix}/cards/:id`, async (request, reply) => {
|
||||
const groupId = groupOf(request);
|
||||
const card = await cards.get(groupId, param(request, 'id'), CLIENT_ACCESS);
|
||||
await cards.setDeleted(groupId, card.id, true);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) =>
|
||||
sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)),
|
||||
app.get(`${prefix}/cards/:id/logo`, async (request, reply) =>
|
||||
sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), 'logo', CLIENT_ACCESS)),
|
||||
);
|
||||
|
||||
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
|
||||
const { id, imageId } = request.params;
|
||||
return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS));
|
||||
});
|
||||
app.get(`${prefix}/cards/:id/images/:imageId`, async (request, reply) =>
|
||||
sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS)),
|
||||
);
|
||||
|
||||
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
|
||||
app.post(`${prefix}/cards/:id/images`, async (request) => {
|
||||
const groupId = groupOf(request);
|
||||
const { files } = await readMultipart(request);
|
||||
return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS);
|
||||
return cards.addImages(groupId, param(request, 'id'), files.get('file') ?? [], CLIENT_ACCESS);
|
||||
});
|
||||
|
||||
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) =>
|
||||
cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS),
|
||||
app.delete(`${prefix}/cards/:id/images/:imageId`, async (request) =>
|
||||
cards.removeImage(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+135
-72
@@ -1,7 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared';
|
||||
import {
|
||||
GROUP_ROLES,
|
||||
buildLoginDeepLink,
|
||||
type AccessKeyView,
|
||||
type Group,
|
||||
type GroupMembership,
|
||||
type GroupRole,
|
||||
type Role,
|
||||
type UserView,
|
||||
} from '@cardy/shared';
|
||||
import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from './errors.js';
|
||||
import { YamlFile } from './yamlFile.js';
|
||||
@@ -15,12 +24,29 @@ const storedKeySchema = z.object({
|
||||
encrypted: z.string(),
|
||||
});
|
||||
|
||||
const storedUserSchema = z.object({
|
||||
email: z.string().transform((s) => s.trim().toLowerCase()),
|
||||
groupId: idSchema,
|
||||
role: z.enum(ROLES),
|
||||
keys: z.array(storedKeySchema).default([]),
|
||||
});
|
||||
const membershipSchema = z.object({ groupId: idSchema, role: z.enum(GROUP_ROLES) });
|
||||
|
||||
/** Users from before multi-group ({ groupId, role }) are read as { superadmin, memberships }. */
|
||||
function upgradeLegacyUser(raw: unknown): unknown {
|
||||
const u = raw as { groupId?: string; role?: string; memberships?: unknown } | null;
|
||||
if (!u || typeof u !== 'object' || u.memberships !== undefined || typeof u.groupId !== 'string') return raw;
|
||||
const { groupId, role, ...rest } = u;
|
||||
return {
|
||||
...rest,
|
||||
superadmin: role === 'superadmin',
|
||||
memberships: [{ groupId, role: role === 'user' ? 'user' : 'admin' }],
|
||||
};
|
||||
}
|
||||
|
||||
const storedUserSchema = z.preprocess(
|
||||
upgradeLegacyUser,
|
||||
z.object({
|
||||
email: z.string().transform((s) => s.trim().toLowerCase()),
|
||||
superadmin: z.boolean().default(false),
|
||||
memberships: z.array(membershipSchema).min(1, 'A user must belong to at least one group'),
|
||||
keys: z.array(storedKeySchema).default([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const usersFileSchema = z.object({
|
||||
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
|
||||
@@ -33,7 +59,8 @@ type UsersFile = z.infer<typeof usersFileSchema>;
|
||||
|
||||
export interface KeyOwner {
|
||||
user: StoredUser;
|
||||
group: Group;
|
||||
/** The user's groups, in membership order (the first one serves the group-less client routes). */
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
export class UsersService {
|
||||
@@ -52,15 +79,14 @@ export class UsersService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */
|
||||
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin flag. */
|
||||
async ensureSuperadmin(email: string, groupName: string): Promise<void> {
|
||||
const data = await this.file.read();
|
||||
const existing = data.users.find((u) => u.email === email);
|
||||
if (existing?.role === 'superadmin') return;
|
||||
if (data.users.find((u) => u.email === email)?.superadmin) return;
|
||||
await this.file.update((d) => {
|
||||
const user = d.users.find((u) => u.email === email);
|
||||
if (user) {
|
||||
user.role = 'superadmin';
|
||||
user.superadmin = true;
|
||||
return;
|
||||
}
|
||||
let group = d.groups.find((g) => sameName(g.name, groupName));
|
||||
@@ -68,7 +94,7 @@ export class UsersService {
|
||||
group = { id: randomUUID(), name: groupName };
|
||||
d.groups.push(group);
|
||||
}
|
||||
d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] });
|
||||
d.users.push({ email, superadmin: true, memberships: [{ groupId: group.id, role: 'admin' }], keys: [] });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +113,15 @@ export class UsersService {
|
||||
return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** The user's groups with their role in each, in membership order. */
|
||||
async membershipsOf(user: StoredUser): Promise<GroupMembership[]> {
|
||||
const { groups } = await this.file.read();
|
||||
return user.memberships.flatMap((m) => {
|
||||
const group = groups.find((g) => g.id === m.groupId);
|
||||
return group ? [{ ...group, role: m.role }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async authenticateKey(key: string): Promise<KeyOwner | undefined> {
|
||||
const data = await this.file.read();
|
||||
if (this.keyIndex?.source !== data) {
|
||||
@@ -98,8 +133,9 @@ export class UsersService {
|
||||
}
|
||||
const email = this.keyIndex.map.get(key);
|
||||
const user = email ? data.users.find((u) => u.email === email) : undefined;
|
||||
const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined;
|
||||
return user && group ? { user, group } : undefined;
|
||||
if (!user) return undefined;
|
||||
const groups = (await this.membershipsOf(user)).map(({ id, name }) => ({ id, name }));
|
||||
return groups.length ? { user, groups } : undefined;
|
||||
}
|
||||
|
||||
// ---- views ----
|
||||
@@ -112,8 +148,15 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
userView(user: StoredUser, apiUrl: string): UserView {
|
||||
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) };
|
||||
/** A member of `groupId`, as shown to the admins of that group. */
|
||||
async userView(user: StoredUser, groupId: string, apiUrl: string): Promise<UserView> {
|
||||
return {
|
||||
email: user.email,
|
||||
superadmin: user.superadmin,
|
||||
role: membership(user, groupId)?.role ?? 'user',
|
||||
groups: await this.membershipsOf(user),
|
||||
keys: this.keyViews(user, apiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- groups (superadmin) ----
|
||||
@@ -139,86 +182,95 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes the group with its users and keys. The caller removes the group's cards. */
|
||||
/**
|
||||
* Removes the group and its memberships. Refused while the group is the only one of some user
|
||||
* (users always belong to at least one group). The caller removes the group's cards.
|
||||
*/
|
||||
async deleteGroup(actor: StoredUser, id: string): Promise<void> {
|
||||
requireSuperadmin(actor);
|
||||
await this.file.update((d) => {
|
||||
if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found');
|
||||
if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) {
|
||||
throw conflict('A group that contains a superadmin cannot be deleted');
|
||||
const onlyGroupOf = d.users.filter((u) => u.memberships.length === 1 && u.memberships[0]!.groupId === id);
|
||||
if (onlyGroupOf.length) {
|
||||
const list = onlyGroupOf.map((u) => u.email).join(', ');
|
||||
throw conflict(`The group is the only group of ${list}. Add them to another group or remove them first.`);
|
||||
}
|
||||
d.groups = d.groups.filter((g) => g.id !== id);
|
||||
d.users = d.users.filter((u) => u.groupId !== id);
|
||||
for (const u of d.users) u.memberships = u.memberships.filter((m) => m.groupId !== id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- users (admin of the current group) ----
|
||||
// ---- members of the current group (its admins) ----
|
||||
|
||||
async listUsers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
|
||||
async listMembers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
|
||||
requireGroupAdmin(actor, groupId);
|
||||
const data = await this.file.read();
|
||||
return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email));
|
||||
return data.users.filter((u) => membership(u, groupId)).sort((a, b) => a.email.localeCompare(b.email));
|
||||
}
|
||||
|
||||
async createUser(
|
||||
actor: StoredUser,
|
||||
currentGroupId: string,
|
||||
input: { email: string; role: Role; groupId?: string },
|
||||
): Promise<StoredUser> {
|
||||
const groupId = input.groupId ?? currentGroupId;
|
||||
if (groupId !== currentGroupId) requireSuperadmin(actor);
|
||||
/** Adds a user to the group; creates the user when the email is new. */
|
||||
async addMember(actor: StoredUser, groupId: string, input: { email: string; role: GroupRole }): Promise<StoredUser> {
|
||||
requireGroupAdmin(actor, groupId);
|
||||
if (input.role === 'superadmin') requireSuperadmin(actor);
|
||||
return this.file.update((d) => {
|
||||
if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
|
||||
if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`);
|
||||
const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] };
|
||||
const existing = d.users.find((u) => u.email === input.email);
|
||||
if (existing) {
|
||||
if (membership(existing, groupId)) throw conflict(`${input.email} is already in this group`);
|
||||
existing.memberships.push({ groupId, role: input.role });
|
||||
return existing;
|
||||
}
|
||||
const user: StoredUser = { email: input.email, superadmin: false, memberships: [{ groupId, role: input.role }], keys: [] };
|
||||
d.users.push(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
/** Changes the role in the group and/or (superadmins only) the global superadmin flag. */
|
||||
async updateMember(
|
||||
actor: StoredUser,
|
||||
currentGroupId: string,
|
||||
groupId: string,
|
||||
email: string,
|
||||
patch: { role?: Role; groupId?: string },
|
||||
patch: { role?: GroupRole; superadmin?: boolean },
|
||||
): Promise<StoredUser> {
|
||||
if (patch.role === 'superadmin') requireSuperadmin(actor);
|
||||
if (patch.groupId !== undefined) requireSuperadmin(actor);
|
||||
if (patch.superadmin !== undefined) requireSuperadmin(actor);
|
||||
return this.file.update((d) => {
|
||||
const user = findManagedUser(d, actor, currentGroupId, email);
|
||||
if (patch.role !== undefined && patch.role !== user.role) {
|
||||
if (user.email === actor.email) throw badRequest('You cannot change your own role');
|
||||
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
|
||||
user.role = patch.role;
|
||||
const user = findManagedMember(d, actor, groupId, email);
|
||||
const self = user.email === actor.email;
|
||||
const m = membership(user, groupId)!;
|
||||
if (patch.role !== undefined && patch.role !== m.role) {
|
||||
if (self) throw badRequest('You cannot change your own role');
|
||||
m.role = patch.role;
|
||||
}
|
||||
if (patch.groupId !== undefined && patch.groupId !== user.groupId) {
|
||||
if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found');
|
||||
user.groupId = patch.groupId;
|
||||
if (patch.superadmin !== undefined && patch.superadmin !== user.superadmin) {
|
||||
if (self) throw badRequest('You cannot change your own superadmin status');
|
||||
if (!patch.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
|
||||
user.superadmin = patch.superadmin;
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise<void> {
|
||||
await this.file.update((d) => {
|
||||
const user = findManagedUser(d, actor, currentGroupId, email);
|
||||
if (user.email === actor.email) throw badRequest('You cannot delete yourself');
|
||||
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
|
||||
/**
|
||||
* Removes the user from the group. For a user whose only group it is, this deletes the user with
|
||||
* all keys (users always belong to at least one group). Returns whether the user was deleted.
|
||||
*/
|
||||
async removeMember(actor: StoredUser, groupId: string, email: string): Promise<{ deleted: boolean }> {
|
||||
return this.file.update((d) => {
|
||||
const user = findManagedMember(d, actor, groupId, email);
|
||||
if (user.email === actor.email) throw badRequest('You cannot remove yourself');
|
||||
if (user.memberships.length > 1) {
|
||||
user.memberships = user.memberships.filter((m) => m.groupId !== groupId);
|
||||
return { deleted: false };
|
||||
}
|
||||
if (user.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
|
||||
d.users = d.users.filter((u) => u !== user);
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
// ---- keys ----
|
||||
// ---- keys (a key signs the user in to all their groups) ----
|
||||
|
||||
async createKey(
|
||||
actor: StoredUser,
|
||||
currentGroupId: string,
|
||||
email: string,
|
||||
label: string | undefined,
|
||||
apiUrl: string,
|
||||
): Promise<AccessKeyView> {
|
||||
async createKey(actor: StoredUser, groupId: string, email: string, label: string | undefined, apiUrl: string): Promise<AccessKeyView> {
|
||||
const plain = generateAccessKey();
|
||||
const stored: StoredKey = {
|
||||
id: randomUUID(),
|
||||
@@ -227,7 +279,7 @@ export class UsersService {
|
||||
encrypted: encryptSecret(plain, this.secret),
|
||||
};
|
||||
await this.file.update((d) => {
|
||||
findManagedUser(d, actor, currentGroupId, email).keys.push(stored);
|
||||
findManagedMember(d, actor, groupId, email).keys.push(stored);
|
||||
});
|
||||
return {
|
||||
id: stored.id,
|
||||
@@ -238,9 +290,9 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> {
|
||||
async deleteKey(actor: StoredUser, groupId: string, email: string, keyId: string): Promise<void> {
|
||||
await this.file.update((d) => {
|
||||
const user = findManagedUser(d, actor, currentGroupId, email);
|
||||
const user = findManagedMember(d, actor, groupId, email);
|
||||
const before = user.keys.length;
|
||||
user.keys = user.keys.filter((k) => k.id !== keyId);
|
||||
if (user.keys.length === before) throw notFound('Key not found');
|
||||
@@ -253,11 +305,22 @@ function sameName(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
function countSuperadmins(d: UsersFile): number {
|
||||
return d.users.filter((u) => u.role === 'superadmin').length;
|
||||
return d.users.filter((u) => u.superadmin).length;
|
||||
}
|
||||
|
||||
export function membership(user: StoredUser, groupId: string) {
|
||||
return user.memberships.find((m) => m.groupId === groupId);
|
||||
}
|
||||
|
||||
/** Effective role in a group; undefined when the user is not a member (and not a superadmin). */
|
||||
export function roleIn(user: StoredUser, groupId: string): Role | undefined {
|
||||
if (user.superadmin) return 'superadmin';
|
||||
return membership(user, groupId)?.role;
|
||||
}
|
||||
|
||||
export function isGroupAdmin(actor: StoredUser, groupId: string): boolean {
|
||||
return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId);
|
||||
const role = roleIn(actor, groupId);
|
||||
return role === 'superadmin' || role === 'admin';
|
||||
}
|
||||
|
||||
export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
|
||||
@@ -265,15 +328,15 @@ export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
|
||||
}
|
||||
|
||||
export function requireSuperadmin(actor: StoredUser): void {
|
||||
if (actor.role !== 'superadmin') throw forbidden();
|
||||
if (!actor.superadmin) throw forbidden();
|
||||
}
|
||||
|
||||
/** A user of the current group that the actor may manage (only superadmins manage superadmins). */
|
||||
function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser {
|
||||
requireGroupAdmin(actor, currentGroupId);
|
||||
/** A member of the group that the actor may manage (only superadmins manage superadmins). */
|
||||
function findManagedMember(d: UsersFile, actor: StoredUser, groupId: string, email: string): StoredUser {
|
||||
requireGroupAdmin(actor, groupId);
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId);
|
||||
if (!user) throw notFound('User not found');
|
||||
if (user.role === 'superadmin') requireSuperadmin(actor);
|
||||
const user = d.users.find((u) => u.email === normalized && membership(u, groupId));
|
||||
if (!user) throw notFound('User not found in this group');
|
||||
if (user.superadmin) requireSuperadmin(actor);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ async function setup() {
|
||||
const { app, config } = await testApp();
|
||||
const root = admin(app, SUPERADMIN);
|
||||
const me = (await root('GET', '/me')).body as MeResponse;
|
||||
return { app, config, root, defaultGroup: me.group };
|
||||
return { app, config, root, defaultGroup: me.currentGroup };
|
||||
}
|
||||
|
||||
describe('admin API authentication', () => {
|
||||
@@ -32,7 +32,13 @@ describe('admin API authentication', () => {
|
||||
const { root } = await setup();
|
||||
const { status, body } = await root('GET', '/me');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } });
|
||||
expect(body).toMatchObject({
|
||||
email: SUPERADMIN,
|
||||
superadmin: true,
|
||||
role: 'superadmin',
|
||||
groups: [{ name: 'default', role: 'admin' }],
|
||||
currentGroup: { name: 'default' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 without header or for unknown users', async () => {
|
||||
@@ -77,18 +83,33 @@ describe('groups and current group', () => {
|
||||
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
|
||||
});
|
||||
|
||||
it('ignores the group cookie for non-superadmins', async () => {
|
||||
it('lets members switch only between their own groups', async () => {
|
||||
const { app, root, defaultGroup } = await setup();
|
||||
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
|
||||
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`);
|
||||
// Not a member of "Other" yet: the cookie is ignored and switching is refused.
|
||||
expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
|
||||
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
|
||||
expect((await adm('GET', '/groups')).status).toBe(403);
|
||||
|
||||
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||
expect((await inOther('POST', '/users', { email: 'adm@example.com' })).status).toBe(201);
|
||||
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(200);
|
||||
const me = (await adm('GET', '/me')).body as MeResponse;
|
||||
expect(me.currentGroup.id).toBe(other.id);
|
||||
expect(me.role).toBe('user');
|
||||
expect(me.groups.map((g) => [g.name, g.role])).toEqual([
|
||||
['default', 'admin'],
|
||||
['Other', 'user'],
|
||||
]);
|
||||
// Admin in "default" only: no admin rights in "Other".
|
||||
expect((await adm('GET', '/users')).status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
|
||||
it('refuses to delete a group that is the only group of some user', async () => {
|
||||
const { app, config, root, defaultGroup } = await setup();
|
||||
// "default" is the superadmin's only group.
|
||||
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
|
||||
|
||||
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||
@@ -97,9 +118,16 @@ describe('groups and current group', () => {
|
||||
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
|
||||
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
|
||||
|
||||
const refused = await root('DELETE', `/groups/${other.id}`);
|
||||
expect(refused.status).toBe(409);
|
||||
expect((refused.body as { error: string }).error).toContain('u@example.com');
|
||||
|
||||
// Once the user has another group, the group can go: its cards are removed, the user stays.
|
||||
await root('POST', '/users', { email: 'u@example.com' });
|
||||
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
|
||||
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
|
||||
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
|
||||
const me = (await admin(app, 'u@example.com')('GET', '/me')).body as MeResponse;
|
||||
expect(me.groups.map((g) => g.id)).toEqual([defaultGroup.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +141,8 @@ describe('users and keys', () => {
|
||||
|
||||
expect((await user('GET', '/users')).status).toBe(403);
|
||||
expect((await user('GET', '/cards')).status).toBe(403);
|
||||
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403);
|
||||
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(400);
|
||||
expect((await adm('PATCH', '/users/user@example.com', { superadmin: true })).status).toBe(403);
|
||||
expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
|
||||
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403);
|
||||
expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400);
|
||||
@@ -121,7 +150,59 @@ describe('users and keys', () => {
|
||||
expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200);
|
||||
expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409);
|
||||
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400);
|
||||
expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400);
|
||||
expect((await root('PATCH', `/users/${SUPERADMIN}`, { superadmin: false })).status).toBe(400);
|
||||
|
||||
const promoted = await root('PATCH', '/users/user@example.com', { superadmin: true });
|
||||
expect((promoted.body as UserView).superadmin).toBe(true);
|
||||
// Now a superadmin: plain admins can no longer manage them.
|
||||
expect((await adm('PATCH', '/users/user@example.com', { role: 'user' })).status).toBe(403);
|
||||
});
|
||||
|
||||
it('keeps roles per group and removes users from one group or entirely', async () => {
|
||||
const { app, root } = await setup();
|
||||
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||
await root('POST', '/users', { email: 'multi@example.com', role: 'admin' });
|
||||
const added = await inOther('POST', '/users', { email: 'multi@example.com', role: 'user' });
|
||||
expect(added.status).toBe(201);
|
||||
expect((added.body as UserView).role).toBe('user');
|
||||
expect((added.body as UserView).groups.map((g) => [g.name, g.role])).toEqual([
|
||||
['default', 'admin'],
|
||||
['Other', 'user'],
|
||||
]);
|
||||
|
||||
// Leaving one group keeps the user.
|
||||
expect((await inOther('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: false });
|
||||
const me = (await admin(app, 'multi@example.com')('GET', '/me')).body as MeResponse;
|
||||
expect(me.groups.map((g) => g.name)).toEqual(['default']);
|
||||
|
||||
// Removing the user from their only group deletes the user.
|
||||
expect((await root('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: true });
|
||||
expect((await admin(app, 'multi@example.com')('GET', '/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('reads users.yaml from before multi-group', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises');
|
||||
const { testConfig } = await import('./helpers.js');
|
||||
const { buildApp } = await import('../src/app.js');
|
||||
const config = await testConfig();
|
||||
await mkdir(config.dataDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(config.dataDir, 'users.yaml'),
|
||||
[
|
||||
'groups:',
|
||||
' - { id: g1, name: Home }',
|
||||
'users:',
|
||||
` - { email: ${SUPERADMIN}, groupId: g1, role: superadmin, keys: [] }`,
|
||||
' - { email: adm@example.com, groupId: g1, role: admin, keys: [] }',
|
||||
' - { email: kid@example.com, groupId: g1, role: user, keys: [] }',
|
||||
].join('\n'),
|
||||
);
|
||||
const app = await buildApp(config);
|
||||
const view = (email: string) => admin(app, email)('GET', '/me').then((r) => r.body as MeResponse);
|
||||
expect(await view(SUPERADMIN)).toMatchObject({ superadmin: true, role: 'superadmin', groups: [{ id: 'g1', role: 'admin' }] });
|
||||
expect(await view('adm@example.com')).toMatchObject({ superadmin: false, role: 'admin', groups: [{ id: 'g1', role: 'admin' }] });
|
||||
expect(await view('kid@example.com')).toMatchObject({ superadmin: false, role: 'user', groups: [{ id: 'g1', role: 'user' }] });
|
||||
});
|
||||
|
||||
it('admin generates keys; users see their own keys with deep links', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AccessKeyView, Card, Group } from '@cardy/shared';
|
||||
import type { AccessKeyView, ApiAuthResponse, Card, Group } from '@cardy/shared';
|
||||
import { SUPERADMIN, multipart, testApp } from './helpers.js';
|
||||
|
||||
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
|
||||
@@ -179,6 +179,42 @@ describe('client API', () => {
|
||||
expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('serves each of the owner\'s groups and keeps /cards on the first one', async () => {
|
||||
const { app, key } = await setup();
|
||||
const api = client(app, key);
|
||||
const other = (
|
||||
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
|
||||
).json() as Group;
|
||||
const stranger = (
|
||||
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Stranger' } })
|
||||
).json() as Group;
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/users',
|
||||
headers: { ...adminHeaders, cookie: `cardy_group=${other.id}` },
|
||||
payload: { email: SUPERADMIN },
|
||||
});
|
||||
|
||||
const auth = (await api('GET', '/auth')).json() as ApiAuthResponse;
|
||||
expect(auth.groups.map((g) => g.name)).toEqual(['default', 'Other']);
|
||||
expect(auth.group.name).toBe('default');
|
||||
|
||||
const inOther = (await api('POST', `/groups/${other.id}/cards`, cardParts({ name: 'In other' }))).json() as Card;
|
||||
const inDefault = (await api('POST', `/groups/${auth.groups[0]!.id}/cards`, cardParts({ name: 'In default' }))).json() as Card;
|
||||
expect(((await api('GET', `/groups/${other.id}/cards`)).json() as Card[]).map((c) => c.name)).toEqual(['In other']);
|
||||
// The group-less routes (apps from before multi-group) use the first group.
|
||||
expect(((await api('GET', '/cards')).json() as Card[]).map((c) => c.name)).toEqual(['In default']);
|
||||
|
||||
// Cards are addressed within their group only.
|
||||
expect((await api('DELETE', `/groups/${other.id}/cards/${inDefault.id}`)).statusCode).toBe(404);
|
||||
expect((await api('PUT', `/groups/${other.id}/cards/${inOther.id}`, [{ name: 'name', value: 'Renamed' }])).json()).toMatchObject({
|
||||
name: 'Renamed',
|
||||
});
|
||||
// Groups the owner is not a member of do not exist for the key.
|
||||
expect((await api('GET', `/groups/${stranger.id}/cards`)).statusCode).toBe(404);
|
||||
expect((await api('POST', `/groups/${stranger.id}/cards`, cardParts())).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns JSON 404 for unknown API routes', async () => {
|
||||
const { app, key } = await setup();
|
||||
const res = await client(app, key)('GET', '/nope');
|
||||
|
||||
Reference in New Issue
Block a user