From e82ffc25494a0f5bc98e9b3dcd50b0337698497b Mon Sep 17 00:00:00 2001 From: Ivan Chikishev Date: Wed, 7 Oct 2026 19:59:21 +0500 Subject: [PATCH 1/3] feat: configurable read-only user profile information --- .../20261007150000_profile_display.cjs | 14 + apps/core/src/app.ts | 2 + apps/core/src/auth/profile-display-config.ts | 105 ++++ apps/core/src/auth/profile-display-routes.ts | 76 +++ apps/core/src/auth/profile-display-schema.ts | 197 ++++++ apps/core/src/auth/profile-display.ts | 110 ++++ .../test/profile-display.integration.test.ts | 168 ++++++ .../components/access/access-workspace.tsx | 7 +- .../access/profile-display-settings.tsx | 320 ++++++++++ .../access/profile-display-source.tsx | 129 ++++ .../components/access/user-profile-dialog.tsx | 5 +- .../components/settings/profile-display.tsx | 164 +++++ .../settings/settings-workspace.tsx | 2 + docs/features/identity.md | 32 + docs/public/openapi.json | 561 ++++++++++++++++++ docs/reference/endpoints.json | 20 + docs/reference/routes.md | 7 +- packages/contracts/locales/ui.en.json | 21 +- packages/contracts/locales/ui.ru.json | 21 +- packages/contracts/src/index.d.ts | 1 + packages/contracts/src/profile-display.d.ts | 42 ++ scripts/docs/http-contracts.mjs | 3 + scripts/docs/profile-display-contract.mjs | 101 ++++ 23 files changed, 2103 insertions(+), 5 deletions(-) create mode 100644 apps/core/migrations/20261007150000_profile_display.cjs create mode 100644 apps/core/src/auth/profile-display-config.ts create mode 100644 apps/core/src/auth/profile-display-routes.ts create mode 100644 apps/core/src/auth/profile-display-schema.ts create mode 100644 apps/core/src/auth/profile-display.ts create mode 100644 apps/core/test/profile-display.integration.test.ts create mode 100644 apps/ui/src/components/access/profile-display-settings.tsx create mode 100644 apps/ui/src/components/access/profile-display-source.tsx create mode 100644 apps/ui/src/components/settings/profile-display.tsx create mode 100644 packages/contracts/src/profile-display.d.ts create mode 100644 scripts/docs/profile-display-contract.mjs diff --git a/apps/core/migrations/20261007150000_profile_display.cjs b/apps/core/migrations/20261007150000_profile_display.cjs new file mode 100644 index 0000000..fbed795 --- /dev/null +++ b/apps/core/migrations/20261007150000_profile_display.cjs @@ -0,0 +1,14 @@ +exports.up = async (knex) => { + await knex.schema + .withSchema("public") + .createTable("asmblyr_profile_display", (table) => { + table.integer("id").primary(); + table.text("title").notNullable().defaultTo(""); + table.jsonb("entries").notNullable().defaultTo("[]"); + }); + await knex("public.asmblyr_profile_display").insert({ id: 1 }); +}; + +exports.down = async (knex) => { + await knex.schema.withSchema("public").dropTable("asmblyr_profile_display"); +}; diff --git a/apps/core/src/app.ts b/apps/core/src/app.ts index 0aebd65..f4a4350 100644 --- a/apps/core/src/app.ts +++ b/apps/core/src/app.ts @@ -20,6 +20,7 @@ import { import { registerUserRoutes } from "./auth/user-routes.js"; import { registerProfileRoutes } from "./auth/profile-routes.js"; import { registerProfileExtensionRoutes } from "./auth/profile-extension-routes.js"; +import { registerProfileDisplayRoutes } from "./auth/profile-display-routes.js"; import { registerProfileAvatarRoutes } from "./auth/profile-avatar-routes.js"; import { registerServiceRoutes } from "./services/routes.js"; import { registerServiceActivity } from "./services/activity.js"; @@ -239,6 +240,7 @@ export function createApp({ registerProfileRoutes(app, database); registerProfileAvatarRoutes(app, database, storageSource); registerProfileExtensionRoutes(app, database, hooks.mutation); + registerProfileDisplayRoutes(app, database); registerPresenceRoutes(app, database); registerNotificationRoutes(app, database, plugins); registerServiceRoutes(app, database); diff --git a/apps/core/src/auth/profile-display-config.ts b/apps/core/src/auth/profile-display-config.ts new file mode 100644 index 0000000..dc6deeb --- /dev/null +++ b/apps/core/src/auth/profile-display-config.ts @@ -0,0 +1,105 @@ +import type { Knex } from "knex"; +import type { + ProfileDisplayConfiguration, + ProfileDisplayEntry, +} from "@asmblyr-collaborative/contracts"; +import { InputError, objectInput, textInput } from "../shared/input.js"; +import { securityEvent } from "./security-events.js"; +import { + parseDisplayPath, + profileDisplaySchema, +} from "./profile-display-schema.js"; + +export interface StoredDisplayEntry extends ProfileDisplayEntry { + signature: string; +} +export interface StoredDisplayConfiguration { + title: string; + entries: StoredDisplayEntry[]; +} + +export async function storedProfileDisplay( + db: Knex, +): Promise { + return await db("public.asmblyr_profile_display") + .where({ id: 1 }) + .first("title", "entries"); +} + +export async function getProfileDisplayConfiguration( + db: Knex, +): Promise { + const config = await storedProfileDisplay(db); + return { + title: config.title, + entries: config.entries.map(({ id, label, path, selfVisible }) => ({ + id, + label, + path, + selfVisible, + })), + }; +} + +function parseConfiguration(input: unknown): ProfileDisplayConfiguration { + const body = objectInput(input, ["title", "entries"]); + const title = textInput(body.title, 100, true); + if (!Array.isArray(body.entries) || body.entries.length > 12) { + throw new InputError("A profile display supports up to 12 entries"); + } + const ids = new Set(); + const entries = body.entries.map((value): ProfileDisplayEntry => { + const entry = objectInput(value, ["id", "label", "path", "selfVisible"]); + if ( + typeof entry.id !== "string" || + !/^[a-zA-Z0-9_-]{1,64}$/.test(entry.id) || + ids.has(entry.id) || + typeof entry.selfVisible !== "boolean" + ) { + throw new InputError("Invalid profile display entry"); + } + ids.add(entry.id); + return { + id: entry.id, + label: textInput(entry.label, 100), + path: parseDisplayPath(entry.path), + selfVisible: entry.selfVisible, + }; + }); + return { title, entries }; +} + +export async function saveProfileDisplayConfiguration( + db: Knex, + input: unknown, + actorId: string, +): Promise { + const config = parseConfiguration(input); + return db.transaction(async (trx) => { + const schema = await profileDisplaySchema(trx); + const entries = config.entries.map((entry) => ({ + ...entry, + signature: schema.resolve(entry.path).signature, + })); + await trx("public.asmblyr_profile_display") + .where({ id: 1 }) + .update({ + title: config.title, + entries: JSON.stringify(entries), + }); + await securityEvent( + trx, + actorId, + "users.profile_display_configured", + actorId, + { + entries: entries.map(({ id, path, selfVisible }) => ({ + id, + path, + selfVisible, + })), + }, + ); + return config; + }); +} diff --git a/apps/core/src/auth/profile-display-routes.ts b/apps/core/src/auth/profile-display-routes.ts new file mode 100644 index 0000000..e5e327f --- /dev/null +++ b/apps/core/src/auth/profile-display-routes.ts @@ -0,0 +1,76 @@ +import type { FastifyInstance } from "fastify"; +import type { Knex } from "knex"; +import { requireSuperuser } from "./require-superuser.js"; +import { requireSettingsRead } from "../settings/access.js"; +import { authenticateAccess } from "./tokens.js"; +import { parseUserId } from "./validation.js"; +import { objectInput, InputError } from "../shared/input.js"; +import { + getProfileDisplayConfiguration, + saveProfileDisplayConfiguration, +} from "./profile-display-config.js"; +import { + parseDisplayPath, + profileDisplaySchema, +} from "./profile-display-schema.js"; +import { readProfileDisplay } from "./profile-display.js"; + +export function registerProfileDisplayRoutes( + app: FastifyInstance, + database: Knex | null, +): void { + function db(): Knex { + if (!database) { + throw Object.assign(new Error("Database is not configured"), { + statusCode: 503, + }); + } + return database; + } + app.get("/users/profile-display", async (request, reply) => { + await requireSuperuser(db(), request); + return reply + .header("Cache-Control", "no-store") + .send({ data: await getProfileDisplayConfiguration(db()) }); + }); + app.put("/users/profile-display", async (request, reply) => { + const actor = await requireSuperuser(db(), request); + return reply.header("Cache-Control", "no-store").send({ + data: await saveProfileDisplayConfiguration(db(), request.body, actor.id), + }); + }); + app.get("/users/profile-display/sources", async (request, reply) => { + await requireSuperuser(db(), request); + const query = objectInput(request.query, ["path"]); + if (query.path !== undefined && typeof query.path !== "string") { + throw new InputError("Invalid profile display path"); + } + const path = parseDisplayPath( + query.path ? (query.path as string).split(".") : [], + true, + ); + const schema = await profileDisplaySchema(db()); + return reply + .header("Cache-Control", "no-store") + .send({ data: schema.choices(path) }); + }); + app.get("/users/me/profile-display", async (request, reply) => { + const user = await authenticateAccess(db(), request.headers.authorization); + return reply + .header("Cache-Control", "no-store") + .send({ data: await readProfileDisplay(db(), user.id, true) }); + }); + app.get<{ Params: { id: string } }>( + "/users/:id/profile-display", + async (request, reply) => { + await requireSettingsRead(db(), request, "users"); + return reply.header("Cache-Control", "no-store").send({ + data: await readProfileDisplay( + db(), + parseUserId(request.params.id), + false, + ), + }); + }, + ); +} diff --git a/apps/core/src/auth/profile-display-schema.ts b/apps/core/src/auth/profile-display-schema.ts new file mode 100644 index 0000000..51701db --- /dev/null +++ b/apps/core/src/auth/profile-display-schema.ts @@ -0,0 +1,197 @@ +import { createHash } from "node:crypto"; +import type { Knex } from "knex"; +import type { + ProfileDisplaySource, + ProfileDisplayValueType, +} from "@asmblyr-collaborative/contracts"; +import { listCollections } from "../collections/catalog-repository.js"; +import type { Collection } from "../collections/types.js"; +import { customFields } from "../system-collections/repository.js"; +import { InputError } from "../shared/input.js"; + +type Field = Collection["fields"][number]; +interface SourceTable { + name: string; + key: string; + fields: Field[]; +} +export interface ProfileDisplayStep { + table: string; + key: string; + field: string; +} +export interface ProfileDisplayBinding { + steps: ProfileDisplayStep[]; + type: ProfileDisplayValueType; + signature: string; +} + +const scalarTypes = new Set([ + "text", + "email", + "integer", + "bigint", + "decimal", + "boolean", + "date", + "datetime", +]); + +function sourceType(field: Field): ProfileDisplaySource["type"] | null { + if (field.presentation?.sensitive) { + return null; + } + if (field.relation?.kind === "m2o") { + return field.relation.collection === "@users" ? "user" : "relation"; + } + if (field.type === "json" && field.presentation?.interface === "tags") { + return "tags"; + } + return scalarTypes.has(field.type) + ? (field.type as ProfileDisplayValueType) + : null; +} + +export function parseDisplayPath(value: unknown, allowEmpty = false): string[] { + if ( + !Array.isArray(value) || + value.length > 3 || + (!allowEmpty && !value.length) || + value.some( + (part) => + typeof part !== "string" || !/^[a-z][a-z0-9_]{0,62}$/.test(part), + ) + ) { + throw new InputError("Invalid profile display path"); + } + return value; +} + +/** Never traverses Core tables except custom user columns and terminal user names. */ +export async function profileDisplaySchema(db: Knex) { + const catalog = await listCollections(db); + const custom = await customFields(db, "users"); + const columns = await db.raw<{ + rows: { table_name: string; column_name: string; identity: string }[]; + }>(` + SELECT c.relname AS table_name, a.attname AS column_name, + c.oid::text || ':' || a.attnum::text || ':' || a.atttypid::text AS identity + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid + WHERE n.nspname = 'public' AND a.attnum > 0 AND NOT a.attisdropped + AND (c.relname = 'asmblyr_users' OR c.relname IN + (SELECT name FROM public.asmblyr_collections)) + `); + const identities = new Map( + columns.rows.map((column) => [ + `${column.table_name}.${column.column_name}`, + column.identity, + ]), + ); + const root: SourceTable = { + name: "asmblyr_users", + key: "id", + fields: custom.map((field) => ({ + ...field.definition, + presentation: field.presentation, + })), + }; + const tables = new Map( + catalog + .filter((collection) => !/^(asmblyr_|plugin_)/i.test(collection.name)) + .map((collection) => [ + collection.name, + { + name: collection.name, + key: collection.primaryKey.name, + fields: collection.fields, + }, + ]), + ); + + function walk(path: string[]) { + let table = root; + const steps: ProfileDisplayStep[] = []; + const signature: unknown[] = []; + let field: Field | undefined; + for (let index = 0; index < path.length; index++) { + field = table.fields.find((entry) => entry.name === path[index]); + const identity = identities.get(`${table.name}.${path[index]}`); + if (!field || !identity || !sourceType(field)) { + throw new InputError("Profile display source is no longer available"); + } + steps.push({ table: table.name, key: table.key, field: field.name }); + signature.push([ + identity, + field.type, + field.relation ?? null, + sourceType(field), + ]); + if (index < path.length - 1) { + const target = + field.relation?.kind === "m2o" + ? tables.get(field.relation.collection) + : undefined; + if (!target) { + throw new InputError( + "Select a single relation in the profile display path", + ); + } + table = target; + } + } + return { table, field, steps, signature }; + } + + function choices(prefix: string[]): ProfileDisplaySource[] { + let table = root; + if (prefix.length) { + const { field } = walk(prefix); + const target = + field?.relation?.kind === "m2o" + ? tables.get(field.relation.collection) + : undefined; + if (!target || prefix.length >= 3) { + return []; + } + table = target; + } + return table.fields.flatMap((field) => { + const type = sourceType(field); + if ( + !type || + (type === "relation" && + (prefix.length >= 2 || + field.relation?.kind !== "m2o" || + !tables.has(field.relation.collection))) + ) { + return []; + } + return [ + { + name: field.name, + label: field.presentation?.label || field.name, + type, + }, + ]; + }); + } + + function resolve(path: string[]): ProfileDisplayBinding { + const { field, steps, signature } = walk(path); + const type = field && sourceType(field); + if (!type || type === "relation") { + throw new InputError( + "Select a value at the end of the profile display path", + ); + } + return { + steps, + type, + signature: createHash("sha256") + .update(JSON.stringify(signature)) + .digest("hex"), + }; + } + return { choices, resolve }; +} diff --git a/apps/core/src/auth/profile-display.ts b/apps/core/src/auth/profile-display.ts new file mode 100644 index 0000000..2c106b7 --- /dev/null +++ b/apps/core/src/auth/profile-display.ts @@ -0,0 +1,110 @@ +import type { Knex } from "knex"; +import type { + ProfileDisplayResult, + ProfileDisplayValue, +} from "@asmblyr-collaborative/contracts"; +import { InputError } from "../shared/input.js"; +import { storedProfileDisplay } from "./profile-display-config.js"; +import { + profileDisplaySchema, + type ProfileDisplayBinding, +} from "./profile-display-schema.js"; +import { UserNotFoundError } from "./validation.js"; + +async function readValue( + db: Knex, + userId: string, + binding: ProfileDisplayBinding, +): Promise { + let value: unknown = userId; + for (const step of binding.steps) { + if (value === null || value === undefined) { + return null; + } + const row = await db(step.table) + .withSchema("public") + .where(step.key, value as string) + .first(step.field); + value = row?.[step.field] ?? null; + } + if (value === null) { + return null; + } + if (binding.type === "user") { + const user = await db("public.asmblyr_users") + .where({ id: value }) + .first("display_name", "first_name", "last_name", "email"); + if (!user) { + return null; + } + return ( + user.display_name?.trim() || + [user.first_name, user.last_name].filter(Boolean).join(" ") || + user.email + ); + } + if (binding.type === "tags") { + return Array.isArray(value) && value.every((tag) => typeof tag === "string") + ? value + : null; + } + if (value instanceof Date) { + return value.toISOString(); + } + return typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ? value + : null; +} + +/** Explicit display grants disclose only configured paths rooted in this user. + * They do not confer collection access or permit arbitrary paths from the caller. */ +export async function readProfileDisplay( + db: Knex, + userId: string, + own: boolean, +): Promise { + return db.transaction( + async (trx) => { + const exists = await trx("public.asmblyr_users") + .where({ id: userId }) + .first("id"); + if (!exists) { + throw new UserNotFoundError(); + } + const config = await storedProfileDisplay(trx); + const selected = config.entries.filter( + (entry) => !own || entry.selfVisible, + ); + if (!selected.length) { + return { title: config.title, entries: [] }; + } + const schema = await profileDisplaySchema(trx); + const entries: ProfileDisplayValue[] = []; + for (const entry of selected) { + let binding: ProfileDisplayBinding; + try { + binding = schema.resolve(entry.path); + } catch (error) { + if (error instanceof InputError) { + continue; + } + throw error; + } + // Deleted/recreated columns or changed relation targets require reconfiguration. + if (binding.signature !== entry.signature) { + continue; + } + entries.push({ + id: entry.id, + label: entry.label, + type: binding.type, + value: await readValue(trx, userId, binding), + }); + } + return { title: config.title, entries }; + }, + { isolationLevel: "repeatable read", readOnly: true }, + ); +} diff --git a/apps/core/test/profile-display.integration.test.ts b/apps/core/test/profile-display.integration.test.ts new file mode 100644 index 0000000..796ca63 --- /dev/null +++ b/apps/core/test/profile-display.integration.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomUUID } from "node:crypto"; +import { systemCollectionFixture } from "./support/system-collections.js"; + +test("profile display discloses only explicitly configured rooted values without granting directory access", async (t) => { + const { db, call, create, admin, member, fieldPath } = + await systemCollectionFixture(t); + const suffix = randomUUID().replaceAll("-", "").slice(0, 8); + const collection = `display_groups_${suffix}`; + const root = `display_group_${suffix}`; + const note = `display_note_${suffix}`; + const before = await db("asmblyr_users") + .where({ id: member.id }) + .first("id", "email", "status", "superuser"); + await call( + "POST", + "/collections", + { + name: collection, + fields: [ + { name: "name", type: "text" }, + { name: "private", type: "text" }, + ], + }, + 201, + ); + await call( + "POST", + `/collections/${collection}/relations`, + { name: "owner", targetCollection: "@users", onDelete: "setNull" }, + 201, + ); + await db("asmblyr_users") + .where({ id: admin.id }) + .update({ display_name: "Alex Manager" }); + const group = await call( + "POST", + `/items/${collection}`, + { name: "Engineering", private: "Must not escape", owner: admin.id }, + 201, + ); + await create("users", root, "relation", { targetCollection: collection }); + await create("users", note); + await call("PUT", `/collections/${collection}/fields/private/presentation`, { + sensitive: true, + }); + await call("PATCH", `/system-collections/users/records/${member.id}`, { + values: { + [root]: group.id, + [note]: "Admin-only note", + }, + }); + const entries = [ + { id: "group", label: "Team", path: [root, "name"], selfVisible: true }, + { id: "lead", label: "Lead", path: [root, "owner"], selfVisible: true }, + { id: "note", label: "Internal", path: [note], selfVisible: false }, + ]; + const config = { title: "Organization", entries }; + await call("PUT", "/users/profile-display", config); + + const own = await call("GET", "/users/me/profile-display", undefined, 200, 1); + assert.deepEqual(own, { + title: "Organization", + entries: [ + { id: "group", label: "Team", type: "text", value: "Engineering" }, + { id: "lead", label: "Lead", type: "user", value: "Alex Manager" }, + ], + }); + assert.equal(JSON.stringify(own).includes("Must not escape"), false); + await call("GET", `/items/${collection}`, undefined, 403, 1); + await call("GET", "/users/references", undefined, 403, 1); + await call("GET", `/users/${admin.id}/profile-display`, undefined, 403, 1); + await call("GET", "/users/profile-display", undefined, 403, 1); + await call("GET", "/users/profile-display/sources", undefined, 403, 1); + await call("PUT", "/users/profile-display", config, 403, 1); + await call("GET", "/users/me/profile-display", undefined, 401, -1); + await call("PATCH", "/users/me/profile-display", { value: "escape" }, 404, 1); + const other = await call("GET", `/users/${member.id}/profile-display`); + assert.equal(other.entries[2].value, "Admin-only note"); + + const sources = await call("GET", "/users/profile-display/sources"); + assert.ok(sources.some((entry: { name: string }) => entry.name === root)); + for (const hidden of ["email", "superuser", "password"]) { + assert.equal( + sources.some((entry: { name: string }) => entry.name === hidden), + false, + ); + } + const invalid = [ + ["email"], + [root, "private"], + [root], + [root, "owner", "email"], + [root, "name", "private"], + [root, "name", "x", "y"], + ["bad;select"], + ]; + for (const path of invalid) { + await call( + "PUT", + "/users/profile-display", + { ...config, entries: [{ ...entries[0], path }] }, + 400, + ); + } + await call( + "PUT", + "/users/profile-display", + { ...config, entries: [entries[0], entries[0]] }, + 400, + ); + assert.deepEqual(await call("GET", "/users/profile-display"), config); + + await db(collection).where({ id: group.id }).update({ name: "Platform" }); + await db("asmblyr_users") + .where({ id: admin.id }) + .update({ display_name: "New Lead" }); + const live = await call( + "GET", + "/users/me/profile-display", + undefined, + 200, + 1, + ); + assert.equal(live.entries[0].value, "Platform"); + assert.equal(live.entries[1].value, "New Lead"); + + // Same-name replacement is not the column the administrator approved. + await call("DELETE", fieldPath("users", note), undefined, 204); + await create("users", note); + await call("PATCH", `/system-collections/users/records/${member.id}`, { + values: { [note]: "Replacement secret" }, + }); + assert.equal( + (await call("GET", `/users/${member.id}/profile-display`)).entries.length, + 2, + ); + + // Marking a configured field sensitive revokes its projection immediately. + await call("PUT", `/collections/${collection}/fields/name/presentation`, { + sensitive: true, + }); + assert.equal( + (await call("GET", "/users/me/profile-display", undefined, 200, 1)).entries + .length, + 1, + ); + await call("PATCH", `/system-collections/users/records/${member.id}`, { + values: { [root]: null }, + }); + assert.equal( + (await call("GET", "/users/me/profile-display", undefined, 200, 1)) + .entries[0].value, + null, + ); + assert.deepEqual( + await db("asmblyr_users") + .where({ id: member.id }) + .first("id", "email", "status", "superuser"), + before, + ); + await call("PUT", "/users/profile-display", { title: "", entries: [] }); + assert.deepEqual( + await call("GET", "/users/me/profile-display", undefined, 200, 1), + { title: "", entries: [] }, + ); +}); diff --git a/apps/ui/src/components/access/access-workspace.tsx b/apps/ui/src/components/access/access-workspace.tsx index 4e1189a..48e0b2a 100644 --- a/apps/ui/src/components/access/access-workspace.tsx +++ b/apps/ui/src/components/access/access-workspace.tsx @@ -9,6 +9,7 @@ import { PageHeader } from "@/components/layout/page-header"; import { PoliciesPanel } from "./policies-panel"; import { UsersPanel } from "./users-panel"; import { ProfileExtensionSettings } from "./profile-extension-settings"; +import { ProfileDisplaySettings } from "./profile-display-settings"; import { accessRequest } from "@/lib/access-request"; import { type AccessUser, type Permission, type Policy } from "./types"; import { useUiCopy } from "@/lib/ui-copy"; @@ -80,7 +81,11 @@ export function AccessWorkspace({ ? copy("Приглашения и доступ участников команды.") : copy("Разрешения на данные и разделы настроек.") } - /> + > + {section === "users" && canManageSystemFields && !readOnly && ( + + )} + {section === "users" && canManagePolicies && !readOnly && ( diff --git a/apps/ui/src/components/access/profile-display-settings.tsx b/apps/ui/src/components/access/profile-display-settings.tsx new file mode 100644 index 0000000..f5f19bf --- /dev/null +++ b/apps/ui/src/components/access/profile-display-settings.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + ArrowUp, + ArrowDown, + Plus, + SlidersHorizontal, + Trash2, +} from "lucide-react"; +import type { + ProfileDisplayConfiguration, + ProfileDisplayEntry, +} from "@asmblyr-collaborative/contracts"; +import { Button } from "@asmblyr-collaborative/kit/ui/button"; +import { Input } from "@asmblyr-collaborative/kit/ui/input"; +import { Checkbox } from "@asmblyr-collaborative/kit/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { EditorDialog } from "@/components/collections/editor-dialog"; +import { apiRequest } from "@/lib/api-request"; +import { useUiCopy } from "@/lib/ui-copy"; +import { ProfileDisplaySourcePicker } from "./profile-display-source"; + +export function ProfileDisplaySettings() { + const copy = useUiCopy(); + const [open, setOpen] = useState(false); + return ( + <> + + {open && setOpen(false)} />} + + ); +} + +function ProfileDisplayEditor({ onClose }: { onClose(): void }) { + const copy = useUiCopy(); + const [config, setConfig] = useState( + null, + ); + const [saved, setSaved] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + useEffect(() => { + let active = true; + apiRequest("/api/users/profile-display") + .then((result) => { + if (active) { + setConfig(result); + setSaved(JSON.stringify(result)); + } + }) + .catch(() => { + if (active) { + setError(copy("Не удалось загрузить настройки профиля")); + } + }); + return () => { + active = false; + }; + }, [copy]); + const dirty = Boolean(config && JSON.stringify(config) !== saved); + function update(id: string, change: Partial) { + setMessage(""); + setConfig( + (current) => + current && { + ...current, + entries: current.entries.map((entry) => + entry.id === id ? { ...entry, ...change } : entry, + ), + }, + ); + } + function move(index: number, delta: number) { + if (!config) { + return; + } + const entries = [...config.entries]; + [entries[index], entries[index + delta]] = [ + entries[index + delta], + entries[index], + ]; + setConfig({ ...config, entries }); + setMessage(""); + } + async function save() { + setPending(true); + setError(""); + setMessage(""); + try { + const next = await apiRequest( + "/api/users/profile-display", + "PUT", + config, + ); + setConfig(next); + setSaved(JSON.stringify(next)); + setMessage(copy("Вид профиля сохранён")); + window.dispatchEvent(new Event("profile-display-updated")); + } catch { + setError( + copy( + "Не удалось сохранить. Проверьте подписи и выберите конечное поле в каждой строке.", + ), + ); + } finally { + setPending(false); + } + } + return ( + + + + } + > + {(container) => ( +
+

+ {copy( + "Выберите информацию для карточки пользователя. Значения берутся из полей и связей и обновляются вместе с исходными данными.", + )} +

+ {config && ( + <> +
+ + { + setConfig({ ...config, title: event.target.value }); + setMessage(""); + }} + /> +
+
+ {config.entries.map((entry, index) => ( +
+
+ +
+ + + +
+
+ + update(entry.id, { label: event.target.value }) + } + /> +
+

+ {copy("Источник значения")} +

+ + update(entry.id, { + path, + ...(!entry.label && label ? { label } : {}), + }) + } + /> +
+
+ + update(entry.id, { selfVisible: checked === true }) + } + /> + +
+
+ ))} +
+ +

+ {copy( + "Настроенные строки видны управляющим пользователями. Галочка дополнительно разрешает владельцу видеть выбранное значение без доступа ко всему справочнику. Изменять эти данные в профиле нельзя.", + )} +

+ + )} + {!config && !error && ( +

+ {copy("Загружаем…")} +

+ )} + {error && ( +

+ {error} +

+ )} + {message && ( +

+ {message} +

+ )} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/access/profile-display-source.tsx b/apps/ui/src/components/access/profile-display-source.tsx new file mode 100644 index 0000000..70f2723 --- /dev/null +++ b/apps/ui/src/components/access/profile-display-source.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ProfileDisplaySource } from "@asmblyr-collaborative/contracts"; +import { ChevronRight } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@asmblyr-collaborative/kit/ui/select"; +import { apiRequest } from "@/lib/api-request"; +import { useUiCopy } from "@/lib/ui-copy"; + +export function ProfileDisplaySourcePicker({ + path, + onChange, + disabled, + container, +}: { + path: string[]; + onChange(path: string[], label?: string): void; + disabled: boolean; + container: HTMLElement | null; +}) { + const copy = useUiCopy(); + const [levels, setLevels] = useState([]); + const [error, setError] = useState(""); + const key = JSON.stringify(path); + useEffect(() => { + let active = true; + async function load() { + const selected = JSON.parse(key) as string[]; + const next: ProfileDisplaySource[][] = []; + for (let level = 0; level < 3; level++) { + const prefix = selected.slice(0, level).join("."); + const options = await apiRequest( + `/api/users/profile-display/sources?path=${encodeURIComponent(prefix)}`, + ); + next.push(options); + const chosen = options.find((entry) => entry.name === selected[level]); + if (chosen?.type !== "relation") { + break; + } + } + if (active) { + setLevels(next); + setError(""); + } + } + void load().catch(() => { + if (active) { + setError(copy("Не удалось загрузить поля")); + } + }); + return () => { + active = false; + }; + }, [key, copy]); + return ( +
+
+ {levels.map((options, level) => ( +
+ {level > 0 && ( + + )} + +
+ ))} +
+ {levels[0]?.length === 0 && ( +

+ {copy( + "Сначала добавьте свои поля в системную коллекцию пользователей.", + )} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/ui/src/components/access/user-profile-dialog.tsx b/apps/ui/src/components/access/user-profile-dialog.tsx index e2e9efd..d4f3483 100644 --- a/apps/ui/src/components/access/user-profile-dialog.tsx +++ b/apps/ui/src/components/access/user-profile-dialog.tsx @@ -7,7 +7,9 @@ import { ProfileExtensionFields } from "@/components/settings/profile-extension- import { ProfileDates } from "@/components/settings/profile-dates"; import { apiRequest } from "@/lib/api-request"; import { useUiCopy } from "@/lib/ui-copy"; +import { userDisplayName } from "@/lib/user-profile"; import { UserSystemFields } from "./user-system-fields"; +import { ProfileDisplay } from "@/components/settings/profile-display"; export function UserProfileDialog({ userId, email, @@ -48,7 +50,7 @@ export function UserProfileDialog({ return ( @@ -72,6 +74,7 @@ export function UserProfileDialog({ )} {user && ( <> + + {copy("Не указано")} + + ); + } + if (entry.type === "tags" && Array.isArray(entry.value)) { + return ( +
+ {entry.value.map((tag, index) => ( + + {tag} + + ))} +
+ ); + } + if (entry.type === "user") { + const name = String(entry.value); + const initials = name + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); + return ( +
+ + + {initials} + + + {name} +
+ ); + } + if (entry.type === "boolean") { + return {entry.value ? copy("Да") : copy("Нет")}; + } + if (entry.type === "date" || entry.type === "datetime") { + const date = new Date(String(entry.value)); + if (!Number.isNaN(date.getTime())) { + return ( + + {entry.type === "date" + ? date.toLocaleDateString(copy.locale, { timeZone: "UTC" }) + : date.toLocaleString(copy.locale)} + + ); + } + } + return ( + + {String(entry.value)} + + ); +} + +export function ProfileDisplay({ + userId, + compact = false, +}: { + userId?: string; + compact?: boolean; +}) { + const copy = useUiCopy(); + const [result, setResult] = useState(null); + const [error, setError] = useState(false); + const [revision, setRevision] = useState(0); + useEffect(() => { + const refresh = () => setRevision((current) => current + 1); + window.addEventListener("profile-display-updated", refresh); + return () => window.removeEventListener("profile-display-updated", refresh); + }, []); + useEffect(() => { + let active = true; + apiRequest( + `/api/users/${userId ?? "me"}/profile-display`, + ) + .then((data) => { + if (active) { + setResult(data); + setError(false); + } + }) + .catch(() => { + if (active) { + setError(true); + } + }); + return () => { + active = false; + }; + }, [userId, revision]); + if (error) { + return ( +
+

{copy("Не удалось загрузить информацию профиля")}

+ +
+ ); + } + if (!result?.entries.length) { + return null; + } + return ( +
+

+ {result.title || copy("Дополнительная информация")} +

+
+ {result.entries.map((entry) => ( +
+
{entry.label}
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/ui/src/components/settings/settings-workspace.tsx b/apps/ui/src/components/settings/settings-workspace.tsx index 8952af7..91abd27 100644 --- a/apps/ui/src/components/settings/settings-workspace.tsx +++ b/apps/ui/src/components/settings/settings-workspace.tsx @@ -25,6 +25,7 @@ import { InitialPasswordForm } from "./initial-password-form"; import { userDisplayName, userAvatarUrl } from "@/lib/user-profile"; import { ProfileExtensionFields } from "./profile-extension-fields"; import { ProfileDates } from "./profile-dates"; +import { ProfileDisplay } from "./profile-display"; export function SettingsWorkspace({ user, @@ -72,6 +73,7 @@ export function SettingsWorkspace({ > {t(user.superuser ? "settings.admin" : "settings.user")} + ({ + type: "object", + additionalProperties: false, + required: Object.keys(properties), + properties, +}); +const entry = object({ + id: { type: "string", pattern: "^[a-zA-Z0-9_-]{1,64}$" }, + label: { type: "string", minLength: 1, maxLength: 100 }, + path: { + type: "array", + minItems: 1, + maxItems: 3, + items: { type: "string", pattern: "^[a-z][a-z0-9_]{0,62}$" }, + }, + selfVisible: { type: "boolean" }, +}); +const config = object({ + title: { type: "string", maxLength: 100 }, + entries: { type: "array", maxItems: 12, items: entry }, +}); +const types = [ + "text", + "email", + "integer", + "bigint", + "decimal", + "boolean", + "date", + "datetime", + "tags", + "user", +]; +const result = object({ + title: string, + entries: { + type: "array", + items: object({ + id: string, + label: string, + type: { enum: types }, + value: { + anyOf: [ + string, + { type: "number" }, + { type: "boolean" }, + { type: "array", items: string }, + { type: "null" }, + ], + }, + }), + }, +}); +const json = (schema) => ({ "application/json": { schema } }); +const responses = (schema) => ({ + 200: { description: "Успех", content: json(object({ data: schema })) }, + default: { description: "Ошибка входа, доступа, проверки или сервера" }, +}); + +export function profileDisplayContract(key) { + if (key === "GET /users/profile-display") { + return { responses: responses(config) }; + } + if (key === "PUT /users/profile-display") { + return { + requestBody: { required: true, content: json(config) }, + responses: responses(config), + }; + } + if (key === "GET /users/profile-display/sources") { + return { + query: [ + { + name: "path", + in: "query", + schema: string, + description: + "Префикс одиночных связей через точку; пустой — дополнительные поля пользователя.", + }, + ], + responses: responses({ + type: "array", + items: object({ + name: string, + label: string, + type: { enum: [...types, "relation"] }, + }), + }), + }; + } + if ( + [ + "GET /users/me/profile-display", + "GET /users/:id/profile-display", + ].includes(key) + ) { + return { responses: responses(result) }; + } + return null; +} From 530010eca34d72687a070d9da8abb37dcebd3e95 Mon Sep 17 00:00:00 2001 From: Ivan Chikishev Date: Wed, 7 Oct 2026 20:11:16 +0500 Subject: [PATCH 2/3] test: clean profile display relations between integration cases --- apps/core/test/profile-display.integration.test.ts | 3 +++ scripts/test.mjs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/core/test/profile-display.integration.test.ts b/apps/core/test/profile-display.integration.test.ts index 796ca63..7462295 100644 --- a/apps/core/test/profile-display.integration.test.ts +++ b/apps/core/test/profile-display.integration.test.ts @@ -165,4 +165,7 @@ test("profile display discloses only explicitly configured rooted values without await call("GET", "/users/me/profile-display", undefined, 200, 1), { title: "", entries: [] }, ); + await call("DELETE", fieldPath("users", root), undefined, 204); + await call("DELETE", fieldPath("users", note), undefined, 204); + await call("DELETE", `/collections/${collection}`, undefined, 204); }); diff --git a/scripts/test.mjs b/scripts/test.mjs index 20be1c7..3cdf2e5 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -175,7 +175,7 @@ async function main() { ); } if (suite === "core-profile") { - return /test\/(?:user-profiles|profile|profile-avatar|profile-extension|preferences|translations|files-access|schema-export|http-access-surface)[-.]/.test( + return /test\/(?:user-profiles|user-relations|profile|profile-avatar|profile-extension|preferences|translations|files-access|schema-export|http-access-surface)[-.]/.test( name, ); } From 479049346316ed53598214dab80a386c0aec6139 Mon Sep 17 00:00:00 2001 From: Ivan Chikishev Date: Wed, 7 Oct 2026 20:24:16 +0500 Subject: [PATCH 3/3] fix: reuse translated profile loading label --- apps/ui/src/components/access/profile-display-settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/access/profile-display-settings.tsx b/apps/ui/src/components/access/profile-display-settings.tsx index f5f19bf..b48ca3e 100644 --- a/apps/ui/src/components/access/profile-display-settings.tsx +++ b/apps/ui/src/components/access/profile-display-settings.tsx @@ -294,7 +294,7 @@ function ProfileDisplayEditor({ onClose }: { onClose(): void }) { role="status" className="text-sm text-muted-foreground" > - {copy("Загружаем…")} + {copy("Загрузка…")}

)} {error && (