From 7c205632cb503580a17b40ab037ab83801aef490 Mon Sep 17 00:00:00 2001 From: James Brumond Date: Fri, 4 Aug 2023 17:39:04 -0700 Subject: [PATCH] start building dashboard --- conf/00-default.yaml | 11 +++- conf/01-local-test.yaml | 15 +++++ package-lock.json | 42 +++++++++++- package.json | 9 ++- src/conf.ts | 2 + src/http-web/assets/color-themes.ts | 20 ++++++ src/http-web/assets/typography.ts | 22 +++++++ src/http-web/authentication/login-page.ts | 28 +++----- src/http-web/root-page.ts | 25 +++----- src/http-web/server.ts | 8 +++ src/http/send-error.ts | 2 +- src/start.ts | 4 ++ src/utilities/color-themes.ts | 65 +++++++++++++++++++ src/utilities/mustache.ts | 32 ++++++++++ templates/login.html.mustache | 21 ++++++ templates/root.html.mustache | 22 +++++++ templates/themes.css.mustache | 78 +++++++++++++++++++++++ templates/typography.css | 26 ++++++++ 18 files changed, 389 insertions(+), 43 deletions(-) create mode 100644 conf/01-local-test.yaml create mode 100644 src/http-web/assets/color-themes.ts create mode 100644 src/http-web/assets/typography.ts create mode 100644 src/utilities/color-themes.ts create mode 100644 src/utilities/mustache.ts create mode 100644 templates/login.html.mustache create mode 100644 templates/root.html.mustache create mode 100644 templates/themes.css.mustache create mode 100644 templates/typography.css diff --git a/conf/00-default.yaml b/conf/00-default.yaml index 75ad5c3..3aa3525 100644 --- a/conf/00-default.yaml +++ b/conf/00-default.yaml @@ -59,4 +59,13 @@ argon2: logging: level: info pretty: false - +color_themes: + default: + light: Minimal Light + dark: Minimal Dark + less_contrast: + light: Minimal Light + dark: Minimal Dark + more_contrast: + light: Minimal Light + dark: Minimal Dark diff --git a/conf/01-local-test.yaml b/conf/01-local-test.yaml new file mode 100644 index 0000000..ae47d3a --- /dev/null +++ b/conf/01-local-test.yaml @@ -0,0 +1,15 @@ +$schema: ../schemas/config.json +http_web: + exposed_url: http://me.local.jbrumond.me:8080 +oidc: + server_url: https://sso.jbrumond.me/realms/public + client_id: local-test-service + client_secret: 7NwC0NEIHGuAa30Lp2V90KDOKOI3YlSt +pkce_cookie: + secure: false +session_cookie: + secure: false + pepper: 4tWRICqVeGtOHaq66RA62aGOhIQ +logging: + level: debug + pretty: true diff --git a/package-lock.json b/package-lock.json index 4969271..3465a9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { - "name": "@templates/nodejs-typescript-service", + "name": "@james/dashboard", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@templates/nodejs-typescript-service", + "name": "@james/dashboard", "version": "1.0.0", "license": "ISC", "dependencies": { + "@doc-utils/color-themes": "^0.1.15", "@fastify/compress": "^6.4.0", "@fastify/etag": "^4.2.0", "@fastify/formbody": "^7.4.0", @@ -17,18 +18,25 @@ "fastify": "^4.19.2", "generic-pool": "^3.9.0", "luxon": "^3.3.0", + "mustache": "^4.2.0", "openid-client": "^5.4.3", "pino": "^8.14.1", "sqlite3": "^5.1.6", "yaml": "^2.3.1" }, "devDependencies": { + "@types/mustache": "^4.2.2", "@types/node": "^20.4.2", "json-schema": "^0.4.0", "pino-pretty": "^10.1.0", "typescript": "^5.1.3" } }, + "node_modules/@doc-utils/color-themes": { + "version": "0.1.15", + "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz", + "integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w==" + }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -163,6 +171,12 @@ "node": ">= 6" } }, + "node_modules/@types/mustache": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", + "integrity": "sha512-MUSpfpW0yZbTgjekDbH0shMYBUD+X/uJJJMm9LXN1d5yjl5lCY1vN/eWKD6D1tOtjA6206K0zcIPnUaFMurdNA==", + "dev": true + }, "node_modules/@types/node": { "version": "20.4.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", @@ -1379,6 +1393,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2324,6 +2346,11 @@ } }, "dependencies": { + "@doc-utils/color-themes": { + "version": "0.1.15", + "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz", + "integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w==" + }, "@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -2442,6 +2469,12 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "optional": true }, + "@types/mustache": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", + "integrity": "sha512-MUSpfpW0yZbTgjekDbH0shMYBUD+X/uJJJMm9LXN1d5yjl5lCY1vN/eWKD6D1tOtjA6206K0zcIPnUaFMurdNA==", + "dev": true + }, "@types/node": { "version": "20.4.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", @@ -3394,6 +3427,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==" + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", diff --git a/package.json b/package.json index 8b19093..78a7eac 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "@templates/nodejs-typescript-service", + "name": "@james/dashboard", "version": "1.0.0", - "description": "Template project for creating new Node.js / TypeScript services", + "description": "Configurable, self-hostable web dashboard", "main": "src/index.ts", "private": true, "repository": { "type": "git", - "url": "git@git.jbrumond.me:templates/nodejs-typescript-service.git" + "url": "git@git.jbrumond.me:james/dashboard.git" }, "scripts": { "tsc": "tsc --build", @@ -16,12 +16,14 @@ "author": "James Brumond ", "license": "ISC", "devDependencies": { + "@types/mustache": "^4.2.2", "@types/node": "^20.4.2", "json-schema": "^0.4.0", "pino-pretty": "^10.1.0", "typescript": "^5.1.3" }, "dependencies": { + "@doc-utils/color-themes": "^0.1.15", "@fastify/compress": "^6.4.0", "@fastify/etag": "^4.2.0", "@fastify/formbody": "^7.4.0", @@ -30,6 +32,7 @@ "fastify": "^4.19.2", "generic-pool": "^3.9.0", "luxon": "^3.3.0", + "mustache": "^4.2.0", "openid-client": "^5.4.3", "pino": "^8.14.1", "sqlite3": "^5.1.6", diff --git a/src/conf.ts b/src/conf.ts index 4daa5c8..14a392c 100644 --- a/src/conf.ts +++ b/src/conf.ts @@ -11,6 +11,7 @@ import { LoggingConfig, validate_logging_conf } from './logger'; import { Argon2HashConfig } from './security/argon-hash'; import { SessionCookieConfig, validate_session_cookie_conf } from './security/session'; import { HttpConfig } from './http/server'; +import { ColorThemeConfig } from './utilities/color-themes'; const conf_dir = process.env.CONF_PATH; @@ -32,6 +33,7 @@ export interface Conf { snowflake_uid: SnowflakeConfig; storage: StorageConfig; argon2: Argon2HashConfig; + color_themes: ColorThemeConfig; } export async function load_conf() : Promise { diff --git a/src/http-web/assets/color-themes.ts b/src/http-web/assets/color-themes.ts new file mode 100644 index 0000000..025930b --- /dev/null +++ b/src/http-web/assets/color-themes.ts @@ -0,0 +1,20 @@ + +import { Req } from '../../http/request'; +import { HttpConfig } from '../../http/server'; +import { HttpWebDependencies } from '../server'; +import { csp_headers } from '../../http/content-security-policy'; +import { FastifyInstance, RouteShorthandOptions } from 'fastify'; + +export function register_color_themes_endpoint(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) { + const opts: RouteShorthandOptions = { + schema: { }, + }; + + http_server.get('/themes.css', opts, async (req: Req, res) => { + res.status(200); + res.header('content-type', 'text/css; charset=utf-8'); + res.header('cache-control', 'public, max-age=3600'); + csp_headers(res, conf.exposed_url); + return color_themes.css; + }); +} diff --git a/src/http-web/assets/typography.ts b/src/http-web/assets/typography.ts new file mode 100644 index 0000000..79eef2c --- /dev/null +++ b/src/http-web/assets/typography.ts @@ -0,0 +1,22 @@ + +import { Req } from '../../http/request'; +import { HttpConfig } from '../../http/server'; +import { HttpWebDependencies } from '../server'; +import { csp_headers } from '../../http/content-security-policy'; +import { FastifyInstance, RouteShorthandOptions } from 'fastify'; +import { load_template } from '../../utilities/mustache'; + +export function register_typography_endpoint(http_server: FastifyInstance, conf: HttpConfig, { }: HttpWebDependencies) { + const opts: RouteShorthandOptions = { + schema: { }, + }; + + http_server.get('/typography.css', opts, async (req: Req, res) => { + res.status(200); + res.header('content-type', 'text/css; charset=utf-8'); + res.header('cache-control', 'no-store'); + // res.header('cache-control', 'public, max-age=3600'); + csp_headers(res, conf.exposed_url); + return load_template('typography.css'); + }); +} diff --git a/src/http-web/authentication/login-page.ts b/src/http-web/authentication/login-page.ts index 84e54fb..e4a4eda 100644 --- a/src/http-web/authentication/login-page.ts +++ b/src/http-web/authentication/login-page.ts @@ -5,6 +5,7 @@ import { ErrorCode, ErrorInfo } from '../../http/send-error'; import { redirect_303_see_other } from '../../http/redirects'; import { FastifyInstance, FastifyReply, RouteShorthandOptions } from 'fastify'; import { csp_headers } from '../../http/content-security-policy'; +import { render_template } from '../../utilities/mustache'; export function register_login_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, session }: HttpWebDependencies) { const opts: RouteShorthandOptions = { @@ -32,22 +33,11 @@ export function register_login_page_endpoint(http_server: FastifyInstance, conf: } } -export const render_login_page = (error_code?: ErrorCode, error?: ErrorInfo) => ` - - -Login - - -
- -
-${error_code ? ` -
-

Login failed

-Error Code: ${error_code}
-Error Message: ${error.message} -
-` : ''} - - -`; +export function render_login_page(error_code?: ErrorCode, error?: ErrorInfo) { + const view = { + error_code, + error, + }; + + return render_template('login.html.mustache', view); +} diff --git a/src/http-web/root-page.ts b/src/http-web/root-page.ts index d08ede6..fd02237 100644 --- a/src/http-web/root-page.ts +++ b/src/http-web/root-page.ts @@ -3,9 +3,9 @@ import { Req } from '../http/request'; import { UserData } from '../storage'; import { HttpConfig } from '../http/server'; import { HttpWebDependencies } from './server'; -import { ErrorCode, ErrorInfo } from '../http/send-error'; import { csp_headers } from '../http/content-security-policy'; import { FastifyInstance, RouteShorthandOptions } from 'fastify'; +import { render_template } from '../utilities/mustache'; export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, logger }: HttpWebDependencies) { const opts: RouteShorthandOptions = { @@ -28,19 +28,10 @@ export function register_root_page_endpoint(http_server: FastifyInstance, conf: }); } -export const render_root_page = (user?: UserData, error_code?: ErrorCode, error?: ErrorInfo) => ` - - -Node.js + TypeScript Service - - -

Node.js + TypeScript Service

-${user -? `

Logged in as ${user.name} (${user.username})

-
- -
` -: 'Login Page'} - - -`; +function render_root_page(user: UserData) { + const view = { + user, + }; + + return render_template('root.html.mustache', view); +} diff --git a/src/http-web/server.ts b/src/http-web/server.ts index f526ef3..57f68ed 100644 --- a/src/http-web/server.ts +++ b/src/http-web/server.ts @@ -14,6 +14,9 @@ import { register_login_page_endpoint } from './authentication/login-page'; import { register_submit_login_endpoint } from './authentication/submit-login'; import { register_login_callback_endpoint } from './authentication/login-callback'; import { register_logout_endpoint } from './authentication/logout'; +import { ColorThemeProvider } from '../utilities/color-themes'; +import { register_color_themes_endpoint } from './assets/color-themes'; +import { register_typography_endpoint } from './assets/typography'; export interface HttpWebDependencies extends BaseHttpDependencies { oidc: OIDCProvider; @@ -22,6 +25,7 @@ export interface HttpWebDependencies extends BaseHttpDependencies { argon2: Argon2HashProvider; session: SessionProvider; snowflake: SnowflakeProvider; + color_themes: ColorThemeProvider; } export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) { @@ -29,6 +33,10 @@ export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependenci endpoints: [ register_csp_report_endpoint, + // Shared Assets + register_color_themes_endpoint, + register_typography_endpoint, + // Root page register_root_page_endpoint, diff --git a/src/http/send-error.ts b/src/http/send-error.ts index 433c9d5..eca2765 100644 --- a/src/http/send-error.ts +++ b/src/http/send-error.ts @@ -30,7 +30,7 @@ export function send_json_error(res: FastifyReply, error_code: ErrorCode) { }; } -export function send_html_error(res: FastifyReply, error_code: ErrorCode, render_html: (code: ErrorCode, error: ErrorInfo) => string) { +export function send_html_error(res: FastifyReply, error_code: ErrorCode, render_html: (code: ErrorCode, error: ErrorInfo) => string | Promise) { const error = errors[error_code]; res.status(error.status); res.header('content-type', 'text/html; charset=utf-8'); diff --git a/src/start.ts b/src/start.ts index 4f06edc..71cdb66 100644 --- a/src/start.ts +++ b/src/start.ts @@ -11,6 +11,7 @@ import { create_session_provider } from './security/session'; import { create_http_metadata_server } from './http-metadata/server'; import { HttpWebDependencies, create_http_web_server } from './http-web/server'; +import { create_color_theme_provider } from './utilities/color-themes'; main(); @@ -37,10 +38,12 @@ async function main() { const snowflake = create_snowflake_provider(conf.snowflake_uid); const argon2 = create_argon_hash_provider(conf.argon2); const session = create_session_provider(conf.session_cookie, logger, argon2, storage); + const color_themes = create_color_theme_provider(conf.color_themes); // Wait for any async init steps await oidc.ready; await storage.ready; + await color_themes.ready; // Perform any cleanup steps before starting up await storage.cleanup_old_sessions(); @@ -53,6 +56,7 @@ async function main() { storage, argon2, session, + color_themes, }; // Create the main web server diff --git a/src/utilities/color-themes.ts b/src/utilities/color-themes.ts new file mode 100644 index 0000000..b177b83 --- /dev/null +++ b/src/utilities/color-themes.ts @@ -0,0 +1,65 @@ + +import { ColorTheme, load_theme } from '@doc-utils/color-themes'; +import { render_template } from './mustache'; + +export interface ColorThemeConfig { + default: LightAndDarkTheme; + less_contrast?: LightAndDarkTheme; + more_contrast?: LightAndDarkTheme; +} + +export interface LightAndDarkTheme { + light: string; + dark: string; +} + +export type ColorThemeProvider = ReturnType; + +export function create_color_theme_provider(conf: ColorThemeConfig) { + let css: string; + const ready = setup(); + + return { + get ready() { + return ready; + }, + get css() { + return css; + }, + }; + + async function setup() { + const view = { + more_contrast: conf.more_contrast != null, + less_contrast: conf.less_contrast != null, + }; + + const partials = { + default_light: render_theme_css_properties(await load(conf.default.light)), + default_dark: render_theme_css_properties(await load(conf.default.dark)), + more_contrast_light: render_theme_css_properties(await load(conf.more_contrast?.light)), + more_contrast_dark: render_theme_css_properties(await load(conf.more_contrast?.dark)), + less_contrast_light: render_theme_css_properties(await load(conf.less_contrast?.light)), + less_contrast_dark: render_theme_css_properties(await load(conf.less_contrast?.dark)), + }; + + css = await render_template('themes.css.mustache', view, partials); + } +} + +function load(theme: string) { + if (theme) { + return load_theme(theme); + } + + return null; +} + +function render_theme_css_properties(theme: ColorTheme) { + return Object + .entries(theme.colors) + .map(([ name, color ]) => { + return `--theme-${name.replace(/_/g, '-')}: ${color};`; + }) + .join('\n') + '\n'; +} diff --git a/src/utilities/mustache.ts b/src/utilities/mustache.ts new file mode 100644 index 0000000..a6a9b54 --- /dev/null +++ b/src/utilities/mustache.ts @@ -0,0 +1,32 @@ + +import { render } from 'mustache'; +import { promises as fs } from 'fs'; +import { resolve as path_resolve } from 'path'; + +const templates: Record> = Object.create(null); +const template_path = path_resolve(__dirname, '../../templates'); + +export async function render_template(file: string, view: any, partials: Record = Object.create(null), partial_files: Record = Object.create(null)) { + const template = await load_template(file); + const partial_promises: Promise[] = [ ]; + + for (const [key, file] of Object.entries(partial_files)) { + const promise = load_template(file).then((template) => { + partials[key] = template; + }); + + partial_promises.push(promise); + } + + await Promise.all(partial_promises); + return render(template, view, partials); +} + +export function load_template(file: string) { + if (! templates[file]) { + const path = path_resolve(template_path, file); + templates[file] = fs.readFile(path, 'utf8'); + } + + return templates[file]; +} diff --git a/templates/login.html.mustache b/templates/login.html.mustache new file mode 100644 index 0000000..c9a3214 --- /dev/null +++ b/templates/login.html.mustache @@ -0,0 +1,21 @@ + + + + Login + + + + +
+ +
+ + {{# error_code }} +
+

Login failed

+ Error Code: {{ error_code }}
+ Error Message: {{ error.message }} +
+ {{/ error_code }} + + \ No newline at end of file diff --git a/templates/root.html.mustache b/templates/root.html.mustache new file mode 100644 index 0000000..1087a59 --- /dev/null +++ b/templates/root.html.mustache @@ -0,0 +1,22 @@ + + + + Dashboard + + + + +

Dashboard

+ + {{# user }} +

Logged in as {{ user.name }} ({{ user.username }})

+
+ +
+ {{/ user }} + + {{^ user }} + Login Page + {{/ user }} + + \ No newline at end of file diff --git a/templates/themes.css.mustache b/templates/themes.css.mustache new file mode 100644 index 0000000..0ebf253 --- /dev/null +++ b/templates/themes.css.mustache @@ -0,0 +1,78 @@ + +:root { + color-scheme: light dark; +} + + + +{{! ===== Default Themes ===== }} + +body { + {{> default_light }} +} + +body[data-color-scheme='dark'] { + {{> default_dark }} +} + +@media (prefers-color-scheme: dark) { + body { + {{> default_dark }} + } + + body[data-color-scheme='light'] { + {{> default_light }} + } +} + + + +{{! ===== High Contrast Themes ===== }} + +{{# more_contrast }} +@media (prefers-contrast: more) { + body { + {{> more_contrast_light }} + } + + body[data-color-scheme='dark'] { + {{> more_contrast_dark }} + } + + @media (prefers-color-scheme: dark) { + body { + {{> more_contrast_dark }} + } + + body[data-color-scheme='light'] { + {{> more_contrast_light }} + } + } +} +{{/ more_contrast }} + + + +{{! ===== Low Contrast Themes ===== }} + +{{# less_contrast }} +@media (prefers-contrast: less) { + body { + {{> less_contrast_light }} + } + + body[data-color-scheme='dark'] { + {{> less_contrast_dark }} + } + + @media (prefers-color-scheme: dark) { + body { + {{> less_contrast_dark }} + } + + body[data-color-scheme='light'] { + {{> less_contrast_light }} + } + } +} +{{/ less_contrast }} diff --git a/templates/typography.css b/templates/typography.css new file mode 100644 index 0000000..726330d --- /dev/null +++ b/templates/typography.css @@ -0,0 +1,26 @@ + +:root { + --font-heading: 'Open Sans', sans-serif; + --font-body: 'Open Sans', sans-serif; + --font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-family: var(--font-body); +} + +h1, h2, h3, h4, h5, h6, +th, dt { + font-family: var(--font-heading); +} + +p, td, dd, figcaption, li, blockquote { + font-family: var(--font-body); +} + +pre, code, samp { + font-family: var(--font-monospace); +} + +a, span, +b, i, u, q, +strong, em, mark, cite { + font-family: inherit; +}