Ссылки на страницы в CRM: «Скопіювати посилання» по link_template, crm:navigate, «Відкрити в CRM» вне iframe (CRM_LINK_TEMPLATE, CRM_AUTO_REDIRECT)

This commit is contained in:
EugeneTes
2026-10-14 10:43:48 +00:00
parent 523bb7cc02
commit 5a16a512e6
7 changed files with 134 additions and 5 deletions

View File

@@ -20,6 +20,15 @@
Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:<uid>`, иначе `id`. Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`. Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:<uid>`, иначе `id`. Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`.
## Ссылки на страницы в CRM
Спецификация: `inbox/a2a-20261014-103541-Спецификация._Ссылки_на_страницы_вики_в_CRM.md` (часть CRM — задача Олега `mv7z4jkhgzaelh`).
- **CRM → вики `crm:context`:** `{ type: "crm:context", version: 1, link_template: "https://<crm>/#/module/<id>?path={path}&ws={ws}" }`. Принимается только с origin из `Crm__Origins`. Формат терпимый: `link_template` (или `linkTemplate`) ищется в самом сообщении `crm:context` или `crm:auth`, а также во вложенных `context` / `crm_context` / `crm`. Шаблон без `{path}` или не http(s) игнорируется.
- **Кнопка «Скопіювати посилання»** (страница и папка): ссылка по `link_template`, `{path}` = `/p/<id>` или `/f/<id>`, `{ws}` = `workspace.id` из CRM (оба через `encodeURIComponent`). Пока шаблона нет — прямая ссылка `https://wiki-crm.tes.gd/p/<id>`. Копирование с запасным путём (textarea + `execCommand`).
- **Вики → CRM `crm:navigate`:** `{ type: "crm:navigate", version: 1, path }` при каждом переходе (страница, папка, главная, назад/вперёд) и один раз после первого `crm:auth`. Только в iframe и только на origin CRM.
- **Вне iframe:** для `/p/<id>` и `/f/<id>` кнопка «Відкрити в CRM» по env `CRM_LINK_TEMPLATE` (`{ws}` пустой). `CRM_AUTO_REDIRECT=true` — сразу `location.replace` туда. Без шаблона всё как раньше. Оба значения отдаёт `GET /api/config` (`crmLinkTemplate`, `crmAutoRedirect`).
## REST API ## REST API
Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`. Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`.

View File

@@ -20,6 +20,15 @@ var crmOrigins = (cfg["Crm:Origins"] ?? cfg["Crm:Origin"] ?? "https://test.units
.Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); .Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
// Откуда грузятся стили CRM (ссылки в index.html). // Откуда грузятся стили CRM (ссылки в index.html).
var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/'); var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
// Ссылка на страницу в CRM для вики, открытой вне iframe: https://<crm>/#/module/<id>?path={path}&ws={ws}.
// Без шаблона (или без {path}) кнопки «Відкрити в CRM» нет. Авторедирект — только CRM_AUTO_REDIRECT=true.
var crmLinkTemplate = (cfg["CRM_LINK_TEMPLATE"] ?? cfg["Crm:LinkTemplate"])?.Trim();
if (string.IsNullOrEmpty(crmLinkTemplate) || !crmLinkTemplate.Contains("{path}")
|| !Uri.TryCreate(crmLinkTemplate.Replace("{path}", "").Replace("{ws}", ""), UriKind.Absolute, out var tplUri)
|| (tplUri.Scheme != "https" && tplUri.Scheme != "http"))
crmLinkTemplate = null;
var crmAutoRedirect = crmLinkTemplate != null
&& string.Equals((cfg["CRM_AUTO_REDIRECT"] ?? cfg["Crm:AutoRedirect"])?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
app.Services.GetRequiredService<Db>().Init(); app.Services.GetRequiredService<Db>().Init();
// ---------------------------------------------------------------- заголовки // ---------------------------------------------------------------- заголовки
@@ -121,7 +130,7 @@ app.UseStaticFiles(new StaticFileOptions
// ---------------------------------------------------------------- открытые // ---------------------------------------------------------------- открытые
app.MapGet("/health", (Db db) => Results.Ok(new { status = "ok", dbCreatedAt = db.Meta("created_at") })); app.MapGet("/health", (Db db) => Results.Ok(new { status = "ok", dbCreatedAt = db.Meta("created_at") }));
app.MapGet("/api/config", () => Results.Ok(new { crmOrigins })); app.MapGet("/api/config", () => Results.Ok(new { crmOrigins, crmLinkTemplate, crmAutoRedirect }));
app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) => app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) =>
{ {

View File

@@ -16,6 +16,11 @@ services:
Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co" Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co"
# publishable-ключ публичный по дизайну # publishable-ключ публичный по дизайну
Supabase__PublishableKey: "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR" Supabase__PublishableKey: "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR"
# вики открыта не в CRM: кнопка «Відкрити в CRM» для /p/<id> и /f/<id>; пусто — кнопки нет
# формат: https://<crm>/#/module/<id>?path={path}&ws={ws}
CRM_LINK_TEMPLATE: ${CRM_LINK_TEMPLATE:-}
# true — сразу перенаправлять в CRM вместо кнопки
CRM_AUTO_REDIRECT: ${CRM_AUTO_REDIRECT:-false}
volumes: volumes:
- wiki-data:/data - wiki-data:/data
healthcheck: healthcheck:

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api"; import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api";
import { retryReady, useAuth } from "./crm"; import { retryReady, useAuth, useShareLink } from "./crm";
import { fillTemplate } from "./links";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs"; import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -28,6 +29,10 @@ function Waiting() {
useEffect(() => { const t = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(t); }, []); useEffect(() => { const t = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(t); }, []);
const d = auth.diag; const d = auth.diag;
const slow = auth.status === "waiting" && now - d.startedAt > 10_000; const slow = auth.status === "waiting" && now - d.startedAt > 10_000;
const route = useRoute();
// Вне iframe: ссылка /p/<id> или /f/<id> открывается в модуле вики CRM (шаблон из CRM_LINK_TEMPLATE).
const openInCrm = auth.status === "standalone" && auth.outsideTemplate && route.kind !== "root"
? fillTemplate(auth.outsideTemplate, hrefOf(route.kind, route.id), "") : null;
const parentOk = d.parentOrigin != null && d.allowed.includes(d.parentOrigin); const parentOk = d.parentOrigin != null && d.allowed.includes(d.parentOrigin);
const text = { const text = {
@@ -48,6 +53,7 @@ function Waiting() {
{auth.status === "error" ? "помилка авторизації" : auth.status === "logout" ? "немає сесії" : auth.status === "checking" ? "перевірка" : "чекаю на авторизацію від CRM"} {auth.status === "error" ? "помилка авторизації" : auth.status === "logout" ? "немає сесії" : auth.status === "checking" ? "перевірка" : "чекаю на авторизацію від CRM"}
</span> </span>
<div className="small mt-6">{text}</div> <div className="small mt-6">{text}</div>
{openInCrm && <div className="row wrap mt-10"><a className="btn sm gold" href={openInCrm}>Відкрити в CRM</a></div>}
</div> </div>
</div> </div>
@@ -171,6 +177,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
<button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Перейменувати</button> <button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Перейменувати</button>
</>} </>}
<button className="btn sm ghost" onClick={() => setDlg({ kind: "move", node: n })}><Icon name="move" />Перемістити</button> <button className="btn sm ghost" onClick={() => setDlg({ kind: "move", node: n })}><Icon name="move" />Перемістити</button>
<ShareButton path={hrefOf(n.kind, n.id)} />
{n.kind === "page" && <CopyButton text={`[${n.title}](/p/${n.id})`} label="Markdown-посилання" {n.kind === "page" && <CopyButton text={`[${n.title}](/p/${n.id})`} label="Markdown-посилання"
title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні" />} title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні" />}
<button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Видалити</button> <button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Видалити</button>
@@ -308,6 +315,13 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
); );
} }
/** «Скопіювати посилання»: ссылка на страницу в CRM (по link_template), пока CRM его не прислала — прямая. */
function ShareButton({ path }: { path: string }) {
const link = useShareLink(path);
return <CopyButton text={link.url} label="Скопіювати посилання"
title={link.inCrm ? "Посилання відкриває цю сторінку в CRM" : "Пряме посилання на вікі (CRM ще не передала шаблон посилання)"} />;
}
function isInside(id: string, ancestor: string, byId: Map<string, Node>) { function isInside(id: string, ancestor: string, byId: Map<string, Node>) {
let cur = byId.get(id); let cur = byId.get(id);
let guard = 0; let guard = 0;

View File

@@ -1,6 +1,8 @@
// Мост с CRM: postMessage (crm:ready → crm:auth, crm:refresh, crm:logout). // Мост с CRM: postMessage (crm:ready → crm:auth, crm:refresh, crm:logout).
// Токен живёт только в памяти, никуда не логируется и не выводится. // Токен живёт только в памяти, никуда не логируется и не выводится.
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { directLink, fillTemplate, templateFrom } from "./links";
import { currentPath, onPathChange, parse } from "./router";
export type User = { id: string; email: string | null; name: string | null }; export type User = { id: string; email: string | null; name: string | null };
export type CrmWorkspace = { id?: string; kind?: string; title?: string }; export type CrmWorkspace = { id?: string; kind?: string; title?: string };
@@ -21,6 +23,9 @@ export type AuthState = {
user?: User; user?: User;
workspace: string; // shared | personal:<uid> | <uuid> workspace: string; // shared | personal:<uid> | <uuid>
workspaceTitle?: string; workspaceTitle?: string;
crmWsId?: string; // id workspace так, как его прислала CRM (для {ws} в ссылке)
linkTemplate?: string; // link_template из crm:context
outsideTemplate?: string; // CRM_LINK_TEMPLATE: вики открыта не в iframe
diag: Diag; diag: Diag;
}; };
@@ -77,6 +82,7 @@ async function onAuth(d: Record<string, unknown>) {
token = t; token = t;
expiresAt = toMs(d.expires_at); expiresAt = toMs(d.expires_at);
if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null; if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null;
set({ crmWsId: rawWsId() });
const ws0 = wsKey(lastWs, state.user?.id); const ws0 = wsKey(lastWs, state.user?.id);
// Если пользователь тот же, повторно не проверяем: каждый запрос к API всё равно проверяется на сервере. // Если пользователь тот же, повторно не проверяем: каждый запрос к API всё равно проверяется на сервере.
if (!changedUser && state.status === "ok") { if (!changedUser && state.status === "ok") {
@@ -103,6 +109,42 @@ async function onAuth(d: Record<string, unknown>) {
} }
} }
function rawWsId() {
const id = lastWs && typeof lastWs === "object" ? lastWs.id : undefined;
return id == null ? undefined : String(id);
}
/** crm:context (или те же поля внутри crm:auth): шаблон ссылки на страницу в CRM. */
function onContext(d: Record<string, unknown>) {
const t = templateFrom(d);
if (t && t !== state.linkTemplate) set({ linkTemplate: t });
}
/** Сообщить CRM текущий путь, чтобы её адрес указывал на эту страницу. CRM без поддержки просто игнорирует. */
function sendNavigate(path: string) {
if (window.parent === window) return;
let target = crmOrigin;
if (!target) {
const p = detectParent().origin;
if (p && allowed.includes(p)) target = p;
}
if (target) window.parent.postMessage({ type: "crm:navigate", version: 1, path }, target);
}
/** Вики открыта не в iframe: кнопка «Відкрити в CRM» и (по флагу) авторедирект для /p/<id> и /f/<id>. */
async function initOutside() {
let cfg: { crmLinkTemplate?: unknown; crmAutoRedirect?: unknown };
try { cfg = await (await fetch("/api/config")).json(); } catch { return; }
const tpl = typeof cfg.crmLinkTemplate === "string" ? cfg.crmLinkTemplate : "";
if (!tpl || !fillTemplate(tpl, "/", "")) return;
set({ outsideTemplate: tpl });
const path = currentPath();
if (cfg.crmAutoRedirect === true && parse(path).kind !== "root") {
const url = fillTemplate(tpl, path, "");
if (url) location.replace(url);
}
}
function flushWaiters() { function flushWaiters() {
const w = waiters; const w = waiters;
waiters = []; waiters = [];
@@ -131,7 +173,7 @@ function sendReady() {
} }
export async function initCrm() { export async function initCrm() {
if (window.parent === window) { set({ status: "standalone" }); return; } if (window.parent === window) { set({ status: "standalone" }); void initOutside(); return; }
const parent = detectParent(); const parent = detectParent();
setDiag({ parentOrigin: parent.origin, parentSource: parent.source, startedAt: Date.now() }); setDiag({ parentOrigin: parent.origin, parentSource: parent.source, startedAt: Date.now() });
@@ -150,6 +192,7 @@ export async function initCrm() {
return; return;
} }
setDiag({ allowed }); setDiag({ allowed });
onPathChange(sendNavigate);
const early = pending ?? []; const early = pending ?? [];
pending = null; pending = null;
early.forEach(handle); early.forEach(handle);
@@ -163,12 +206,18 @@ function handle(ev: MessageEvent) {
const rec = state.diag.received; const rec = state.diag.received;
if (rec.length < 20) setDiag({ received: [...rec, { origin: ev.origin, type, accepted: ok, why: ok ? undefined : "origin не в списку CRM" }] }); if (rec.length < 20) setDiag({ received: [...rec, { origin: ev.origin, type, accepted: ok, why: ok ? undefined : "origin не в списку CRM" }] });
if (!ok || !d || typeof d !== "object") return; if (!ok || !d || typeof d !== "object") return;
if (d.type === "crm:auth") { crmOrigin = ev.origin; void onAuth(d); } if (d.type === "crm:auth") {
const first = !crmOrigin;
crmOrigin = ev.origin;
onContext(d);
void onAuth(d);
if (first) sendNavigate(currentPath()); // первый раз: CRM узнаёт, где сейчас вики
} else if (d.type === "crm:context") onContext(d);
else if (d.type === "crm:workspace") { else if (d.type === "crm:workspace") {
// Будущее сообщение о смене workspace (задача Олега); формат: { workspace: { id, kind, title } }. // Будущее сообщение о смене workspace (задача Олега); формат: { workspace: { id, kind, title } }.
lastWs = d.workspace ?? null; lastWs = d.workspace ?? null;
const ws = wsKey(lastWs, state.user?.id); const ws = wsKey(lastWs, state.user?.id);
set({ workspace: ws.id, workspaceTitle: ws.title }); set({ workspace: ws.id, workspaceTitle: ws.title, crmWsId: rawWsId() });
} else if (d.type === "crm:logout") { } else if (d.type === "crm:logout") {
token = null; expiresAt = null; token = null; expiresAt = null;
set({ status: "logout", user: undefined }); set({ status: "logout", user: undefined });
@@ -202,6 +251,13 @@ export async function getToken(force = false): Promise<string> {
return token; return token;
} }
/** Ссылка на путь вики для копирования: в CRM по link_template, пока его нет — прямая. */
export function useShareLink(path: string): { url: string; inCrm: boolean } {
const a = useAuth();
const url = a.linkTemplate ? fillTemplate(a.linkTemplate, path, a.crmWsId ?? "") : null;
return url ? { url, inCrm: true } : { url: directLink(path), inCrm: false };
}
export function currentWorkspace() { export function currentWorkspace() {
return state.workspace; return state.workspace;
} }

32
frontend/src/links.ts Normal file
View File

@@ -0,0 +1,32 @@
// Ссылки на страницы вики внутри CRM (deep link модуля вики).
// Шаблон: https://<crm>/#/module/<id>?path={path}&ws={ws} — из crm:context или из env CRM_LINK_TEMPLATE.
/** Подставить путь (/p/<id>, /f/<id>, /) и workspace CRM. null — шаблон негодный. */
export function fillTemplate(tpl: string, path: string, ws: string): string | null {
if (!tpl.includes("{path}")) return null;
const url = tpl.split("{path}").join(encodeURIComponent(path)).split("{ws}").join(encodeURIComponent(ws));
try {
const u = new URL(url);
return u.protocol === "https:" || u.protocol === "http:" ? url : null;
} catch {
return null;
}
}
/** Прямая ссылка на вики (пока CRM не прислала crm:context). */
export const directLink = (path: string) => location.origin + path;
/**
* link_template из сообщения CRM. Формат терпимый: поля могут лежать прямо в сообщении
* (crm:context или crm:auth) или во вложенном объекте context / crm_context / crm; имя — link_template или linkTemplate.
*/
export function templateFrom(d: Record<string, unknown>): string | null {
const boxes = [d, d.context, d.crm_context, d.crm];
for (const b of boxes) {
if (!b || typeof b !== "object") continue;
const o = b as Record<string, unknown>;
const t = o.link_template ?? o.linkTemplate;
if (typeof t === "string" && fillTemplate(t, "/", "") != null) return t;
}
return null;
}

View File

@@ -9,6 +9,10 @@ const subs = new Set<() => void>();
let path = location.pathname; let path = location.pathname;
window.addEventListener("popstate", () => { path = location.pathname; subs.forEach((f) => f()); }); window.addEventListener("popstate", () => { path = location.pathname; subs.forEach((f) => f()); });
export const currentPath = () => path;
/** Подписка на каждый переход (в том числе назад/вперёд). */
export function onPathChange(f: (p: string) => void) { subs.add(() => f(path)); }
let guard: (() => Promise<boolean>) | null = null; let guard: (() => Promise<boolean>) | null = null;
/** Перед уходом со страницы: дописать изменения. false — уйти нельзя (не сохранилось, причина видна на странице). */ /** Перед уходом со страницы: дописать изменения. false — уйти нельзя (не сохранилось, причина видна на странице). */
export function setLeaveGuard(g: (() => Promise<boolean>) | null) { guard = g; } export function setLeaveGuard(g: (() => Promise<boolean>) | null) { guard = g; }