Ссылки на страницы в CRM: «Скопіювати посилання» по link_template, crm:navigate, «Відкрити в CRM» вне iframe (CRM_LINK_TEMPLATE, CRM_AUTO_REDIRECT)
This commit is contained in:
@@ -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
|
||||
|
||||
Спецификация: `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
|
||||
|
||||
Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`.
|
||||
|
||||
@@ -20,6 +20,15 @@ var crmOrigins = (cfg["Crm:Origins"] ?? cfg["Crm:Origin"] ?? "https://test.units
|
||||
.Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
// Откуда грузятся стили CRM (ссылки в index.html).
|
||||
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();
|
||||
|
||||
// ---------------------------------------------------------------- заголовки
|
||||
@@ -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("/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) =>
|
||||
{
|
||||
|
||||
@@ -16,6 +16,11 @@ services:
|
||||
Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co"
|
||||
# publishable-ключ публичный по дизайну
|
||||
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:
|
||||
- wiki-data:/data
|
||||
healthcheck:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
@@ -28,6 +29,10 @@ function Waiting() {
|
||||
useEffect(() => { const t = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(t); }, []);
|
||||
const d = auth.diag;
|
||||
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 text = {
|
||||
@@ -48,6 +53,7 @@ function Waiting() {
|
||||
{auth.status === "error" ? "помилка авторизації" : auth.status === "logout" ? "немає сесії" : auth.status === "checking" ? "перевірка" : "чекаю на авторизацію від CRM"}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
@@ -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: "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-посилання"
|
||||
title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні" />}
|
||||
<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>) {
|
||||
let cur = byId.get(id);
|
||||
let guard = 0;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Мост с CRM: postMessage (crm:ready → crm:auth, crm:refresh, crm:logout).
|
||||
// Токен живёт только в памяти, никуда не логируется и не выводится.
|
||||
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 CrmWorkspace = { id?: string; kind?: string; title?: string };
|
||||
@@ -21,6 +23,9 @@ export type AuthState = {
|
||||
user?: User;
|
||||
workspace: string; // shared | personal:<uid> | <uuid>
|
||||
workspaceTitle?: string;
|
||||
crmWsId?: string; // id workspace так, как его прислала CRM (для {ws} в ссылке)
|
||||
linkTemplate?: string; // link_template из crm:context
|
||||
outsideTemplate?: string; // CRM_LINK_TEMPLATE: вики открыта не в iframe
|
||||
diag: Diag;
|
||||
};
|
||||
|
||||
@@ -77,6 +82,7 @@ async function onAuth(d: Record<string, unknown>) {
|
||||
token = t;
|
||||
expiresAt = toMs(d.expires_at);
|
||||
if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null;
|
||||
set({ crmWsId: rawWsId() });
|
||||
const ws0 = wsKey(lastWs, state.user?.id);
|
||||
// Если пользователь тот же, повторно не проверяем: каждый запрос к API всё равно проверяется на сервере.
|
||||
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() {
|
||||
const w = waiters;
|
||||
waiters = [];
|
||||
@@ -131,7 +173,7 @@ function sendReady() {
|
||||
}
|
||||
|
||||
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();
|
||||
setDiag({ parentOrigin: parent.origin, parentSource: parent.source, startedAt: Date.now() });
|
||||
|
||||
@@ -150,6 +192,7 @@ export async function initCrm() {
|
||||
return;
|
||||
}
|
||||
setDiag({ allowed });
|
||||
onPathChange(sendNavigate);
|
||||
const early = pending ?? [];
|
||||
pending = null;
|
||||
early.forEach(handle);
|
||||
@@ -163,12 +206,18 @@ function handle(ev: MessageEvent) {
|
||||
const rec = state.diag.received;
|
||||
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 (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") {
|
||||
// Будущее сообщение о смене workspace (задача Олега); формат: { workspace: { id, kind, title } }.
|
||||
lastWs = d.workspace ?? null;
|
||||
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") {
|
||||
token = null; expiresAt = null;
|
||||
set({ status: "logout", user: undefined });
|
||||
@@ -202,6 +251,13 @@ export async function getToken(force = false): Promise<string> {
|
||||
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() {
|
||||
return state.workspace;
|
||||
}
|
||||
|
||||
32
frontend/src/links.ts
Normal file
32
frontend/src/links.ts
Normal 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;
|
||||
}
|
||||
@@ -9,6 +9,10 @@ const subs = new Set<() => void>();
|
||||
let path = location.pathname;
|
||||
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;
|
||||
/** Перед уходом со страницы: дописать изменения. false — уйти нельзя (не сохранилось, причина видна на странице). */
|
||||
export function setLeaveGuard(g: (() => Promise<boolean>) | null) { guard = g; }
|
||||
|
||||
Reference in New Issue
Block a user