@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
316 lines (287 loc) • 9.98 kB
JavaScript
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Liquid } from "liquidjs";
import { flatten } from "flat";
//#region src/controllers/mcp/oauth-consent-page.ts
const liquid = new Liquid({
root: join(dirname(fileURLToPath(import.meta.url)), "templates"),
extname: ".liquid",
outputEscape: "escape"
});
const logoDataUri = `data:image/svg+xml,${encodeURIComponent(`<svg width="64" height="39" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" d="M51.18 24.61c-.3-.07-.55-.15-.77-.25-.16-.07-.3-.16-.42-.26a.31.31 0 0 1-.1-.27c.11-1.24-.02-2.33.1-3.56.5-5 3.64-3.41 6.46-4.23 1.62-.45 3.24-1.35 3.83-3.1.1-.3.01-.61-.19-.84a36.2 36.2 0 0 0-6.12-5.54A36.73 36.73 0 0 0 27.94.36a.46.46 0 0 0-.33.7 13.7 13.7 0 0 0 4.31 4.24c.31.19.19.6-.17.52a8.2 8.2 0 0 1-2.92-1.26.35.35 0 0 0-.33-.04l-1.64.67a.45.45 0 0 0-.12.75A13.7 13.7 0 0 0 42.8 7.3c.3-.19.8.2.7.55a27 27 0 0 0-.54 2.37c-1.26 6.37-4.9 5.87-9.4 4.27-9-3.26-14.12-.48-18.66-5.98-.31-.38-.87-.51-1.24-.19a4.25 4.25 0 0 0 .43 6.8c.14.1.33.05.44-.08.28-.35.5-.59.8-.74.3-.16.46.29.2.52-.97.85-1.25 1.87-1.88 3.87-.99 3.13-.57 6.34-5.2 7.18-2.45.12-2.4 1.78-3.29 4.25-1.03 2.98-2.39 4.3-4.9 6.9-.34.36-.36.93.01 1.25 1 .85 2.04.9 3.09.46 2.6-1.08 4.6-4.44 6.48-6.61 2.1-2.42 7.15-1.39 10.97-3.76 2.05-1.25 3.29-2.86 2.9-5.27-.07-.38.37-.62.53-.26.31.68.51 1.4.6 2.16.02.2.2.34.39.33 4.12-.23 9.46 4.3 14.44 5.53.3.08.52-.27.35-.53a9.17 9.17 0 0 1-1.3-3.02c-.1-.39.47-.5.66-.14a9.2 9.2 0 0 0 7.4 4.71c1.2.1 2.54-.05 3.93-.47 1.66-.5 3.19-1.14 5.02-.79 1.36.25 2.63.94 3.42 2.1 1.1 1.62 3.45 2.04 4.7.35a.81.81 0 0 0 .08-.8c-2.76-6.43-9.75-6.88-12.75-7.65Z"/></svg>`)}`;
const baseRules = {
borderRadius: "0.3125rem",
borderWidth: "2px",
primary: "var(--project-color)",
primaryBackground: "color-mix(in srgb, var(--theme--background), var(--theme--primary) 10%)",
primarySubdued: "color-mix(in srgb, var(--theme--background), var(--theme--primary) 50%)",
danger: "#e35169",
fonts: {
display: {
fontFamily: "\"Inter\", system-ui",
fontWeight: "700"
},
sans: {
fontFamily: "\"Inter\", system-ui",
fontWeight: "500"
},
monospace: {
fontFamily: "\"Fira Mono\", monospace",
fontWeight: "500"
}
},
form: { field: { input: {
background: "var(--theme--background)",
foreground: "var(--theme--foreground)",
foregroundSubdued: "var(--theme--foreground-subdued)",
borderColor: "var(--theme--border-color)",
borderColorHover: "var(--theme--border-color-accent)",
padding: "0.875rem"
} } },
public: {
background: "var(--theme--background)",
foreground: "var(--theme--foreground)",
foregroundAccent: "var(--theme--foreground-accent)"
}
};
const lightRules = {
...baseRules,
foreground: "#4f5464",
foregroundAccent: "#172940",
foregroundSubdued: "#a2b5cd",
background: "#fff",
backgroundNormal: "#f0f4f9",
backgroundAccent: "#e4eaf1",
backgroundSubdued: "#f7fafc",
borderColor: "#e4eaf1",
borderColorAccent: "#d3dae4",
borderColorSubdued: "#f0f4f9",
primaryAccent: "color-mix(in srgb, var(--theme--primary), #2e3c43 25%)"
};
const darkRules = {
...baseRules,
foreground: "#c9d1d9",
foregroundAccent: "#f0f6fc",
foregroundSubdued: "#666672",
background: "#0d1117",
backgroundNormal: "#21262e",
backgroundAccent: "#30363d",
backgroundSubdued: "#161b22",
borderColor: "#21262e",
borderColorAccent: "#30363d",
borderColorSubdued: "#21262d",
primaryAccent: "color-mix(in srgb, var(--theme--primary), #16151a 25%)"
};
function camelToKebab(str) {
return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
function rulesToCssVars(rules) {
const flattened = flatten(rules, { delimiter: "--" });
const result = {};
for (const [key, value] of Object.entries(flattened)) result[`--theme--${camelToKebab(key)}`] = value;
return result;
}
function varsToCss(vars) {
return Object.entries(vars).map(([key, value]) => `${key}: ${value};`).join("\n ");
}
const lightCss = varsToCss(rulesToCssVars(lightRules));
const darkCss = varsToCss(rulesToCssVars(darkRules));
/**
* Generate theme CSS with light/dark mode support.
* Inlines Directus theme tokens as CSS custom properties (same logic as @directus/themes
* `rulesToCssVars`). Uses `--project-color` as the primary color seed.
*
* @param projectColor - Hex color, already validated by `validateProjectColor`
*/
function buildStyles(projectColor) {
return `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--project-color: ${projectColor};
${lightCss}
}
[data-theme="dark"] {
${darkCss}
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
${darkCss}
}
}
body {
font-family: var(--theme--fonts--sans--font-family);
font-weight: var(--theme--fonts--sans--font-weight);
background: var(--theme--background-subdued);
color: var(--theme--public--foreground-accent);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
-webkit-font-smoothing: antialiased;
}
.card {
background: var(--theme--public--background);
color: var(--theme--public--foreground);
border-radius: calc(var(--theme--border-radius) * 3);
padding: 1.5rem;
max-width: 576px;
width: 100%;
box-shadow: 0 0 40px 0 rgb(38 50 56 / 0.1);
}
@media (min-width: 480px) {
.card { padding: 2.5rem 2.5rem 2rem; }
}
.logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
inline-size: 3.125rem;
block-size: 3.125rem;
border-radius: calc(var(--theme--border-radius) - 0.125rem);
background: var(--theme--primary);
overflow: hidden;
}
.logo img {
object-fit: contain;
object-position: center center;
block-size: 2.25rem;
inline-size: 2.25rem;
}
h1 {
font-family: var(--theme--fonts--display--font-family);
font-weight: var(--theme--fonts--display--font-weight);
font-size: 1.25rem;
text-align: center;
margin-bottom: 1.5rem;
}
h1 strong { font-weight: 700; }
.details {
border: var(--theme--border-width) solid var(--theme--form--field--input--border-color);
border-radius: var(--theme--border-radius);
overflow: hidden;
margin-bottom: 1.25rem;
}
.details-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--theme--foreground-subdued);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.625rem var(--theme--form--field--input--padding);
border-bottom: var(--theme--border-width) solid var(--theme--form--field--input--border-color);
background: var(--theme--background-subdued);
}
.detail-row {
display: flex;
align-items: baseline;
padding: 0.5rem var(--theme--form--field--input--padding);
font-size: 0.875rem;
border-bottom: var(--theme--border-width) solid var(--theme--border-color-subdued);
}
.detail-row:last-child { border-bottom: none; }
.detail-key {
color: var(--theme--foreground-subdued);
min-width: 7rem;
flex-shrink: 0;
}
@media (max-width: 479px) {
.detail-row { flex-direction: column; gap: 0.125rem; }
.detail-key { min-width: 0; }
}
.detail-value {
color: var(--theme--foreground-accent);
word-break: break-all;
font-family: var(--theme--fonts--monospace--font-family);
font-size: 0.8125rem;
}
.detail-value.name {
font-family: var(--theme--fonts--sans--font-family);
font-size: 0.875rem;
font-weight: 600;
}
.note {
margin-bottom: 1.5rem;
line-height: 1.6;
color: var(--theme--foreground-subdued);
font-size: 0.875rem;
}
.note strong { color: var(--theme--foreground); }
.actions {
display: flex;
justify-content: flex-end;
gap: 0.625rem;
}
button {
padding: 0.5rem 1.25rem;
border: var(--theme--border-width) solid transparent;
border-radius: var(--theme--border-radius);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
button:hover { opacity: 0.85; }
button:active { opacity: 0.7; }
.btn-approve { background: var(--theme--primary); color: #fff; }
.btn-cancel { background: var(--theme--background); color: var(--theme--foreground); border-color: var(--theme--border-color-accent); }
.redirect-warn {
font-size: 0.75rem;
color: var(--theme--danger);
font-family: var(--theme--fonts--sans--font-family);
}`;
}
const DEFAULT_PROJECT_COLOR = "#6644ff";
function validateProjectColor(color) {
return /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : DEFAULT_PROJECT_COLOR;
}
/**
* Build shared template variables from page options.
*
* Template variables:
* - `styles` (string, `| raw`): generated CSS with theme variables
* - `themeAttr` (string, `| raw`): `data-theme="dark|light"` or empty for auto
* - `logoUrl` / `logoDataUri`: custom project logo URL or default Directus logo as data URI
* - `projectName`: escaped by Liquid auto-escape, safe for text content
*/
function buildTemplateData(opts) {
const customLogo = !!opts.logoUrl;
let themeAttr = "";
if (opts.appearance === "dark") themeAttr = " data-theme=\"dark\"";
else if (opts.appearance === "light") themeAttr = " data-theme=\"light\"";
const validColor = validateProjectColor(opts.projectColor);
return {
projectName: opts.projectName,
styles: buildStyles(validColor),
themeAttr,
logoClass: "logo",
logoUrl: customLogo ? opts.logoUrl : null,
logoDataUri: !customLogo ? logoDataUri : null,
logoAlt: customLogo ? opts.projectName : "Directus"
};
}
/** Render the OAuth consent approval/deny page. */
async function renderConsentPage(data, opts) {
return liquid.renderFile("oauth-consent", {
...buildTemplateData(opts),
...data
});
}
/** Render the OAuth error page (pre-redirect errors that can't be sent to the client). */
async function renderErrorPage(description, opts) {
return liquid.renderFile("oauth-error", {
...buildTemplateData(opts),
description
});
}
//#endregion
export { renderConsentPage, renderErrorPage };