Close the last three UI leftovers: shared staff HTML, cookie domain, stock Swagger label.
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
verae-staff-ui holds one review template for CS and access-staff. Staff cookies take STAFF_COOKIE_DOMAIN for a reverse-proxy host. /docs stays vendor Swagger with an integrator banner only.
This commit is contained in:
parent
b68fefdea8
commit
d8efffe8be
23 changed files with 368 additions and 44 deletions
|
|
@ -34,6 +34,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
|
|||
| **zapier-user-docs** | `packages/zapier-user-docs` | Customer signup → register → lookup |
|
||||
| **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF |
|
||||
| **verae-staff-session** | `packages/verae-staff-session` | Shared staff cookie login for department HTML |
|
||||
| **verae-staff-ui** | `packages/verae-staff-ui` | Shared staff review HTML (CS + access-staff) |
|
||||
| **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` |
|
||||
| **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services |
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ Follow-up to the 2026-09-11 review PDF. Items the review listed as open are now
|
|||
|
||||
## Still to consider
|
||||
|
||||
| Severity | Issue | Suggestion |
|
||||
|----------|-------|------------|
|
||||
| Med | Swagger `/docs` remains stock | Keep it for integrators; do not skin. Portal already labels it as stock OpenAPI explorer. |
|
||||
| Low | access-staff and CS HTML are twins | Share one template file if those packages ever merge. |
|
||||
| Low | Staff cookie is host-scoped (`127.0.0.1`) | Fine on one operator host. For multiple DNS names, put a reverse proxy in front. |
|
||||
None of the previous review leftovers are open.
|
||||
|
||||
| Severity | Issue | What shipped |
|
||||
|----------|-------|----------------|
|
||||
| Med | Swagger `/docs` stock | Left as vendor Swagger. Title + banner: “OpenAPI explorer — stock Swagger UI for integrators.” Not skinned. |
|
||||
| Low | access-staff and CS HTML twins | Shared template in `verae-staff-ui` (`review.html` + tokens). Both servers load it in the monorepo. |
|
||||
| Low | Staff cookie host-scoped | `STAFF_COOKIE_DOMAIN` / `STAFF_COOKIE_SECURE` plus nginx example on `verae-staff-session`. |
|
||||
|
||||
## How to use each surface
|
||||
|
||||
|
|
|
|||
|
|
@ -527,7 +527,7 @@ endobj
|
|||
endobj
|
||||
56 0 obj
|
||||
<<
|
||||
/Author (UI-Docs) /CreationDate (D:20260911181807-04'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260911181807-04'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Author (UI-Docs) /CreationDate (D:20260911182248-04'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260911182248-04'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Verae Time \327 Zapier UI review) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
|
|
@ -540,10 +540,10 @@ endobj
|
|||
endobj
|
||||
58 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2126
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2109
|
||||
>>
|
||||
stream
|
||||
Gau0DgN)%,&:O:Sm%`=?BS&0?p@Q1\lc-'UVNoTTF>D<r8Ou09&e"eD<kl?R+=Z!(_o/]>)X@HTMnPWD]5'l"?Hg)q-.2HC3kf"?!h.Aj7:&Bg/,E[tnF=:%rCQ9Ej9=QlB&of`'i(]1*)q0+4H:i+;_4b7d/;W#aM81qS]'"bS9RUQ&$)eD3f`_%KOuMNrZN14BI6A<\+CK?V.b\A#K`@sh'E3]8?quFAfk^[XVKhaCm+QBGu59]E(jRX`6kBmF=06^]f6KM%'21nK\_d[)[u$C-Et@r)J8*(\T_T]j?t%r";EhV"jT;t"C@N"fUh2H\C2E>[;&BbrTN)mVp#6cOu2$po/3EA'Xm9\R^i+r<h$ukH*K-J7]dKM,LT^>7&?6mkTa8H^(<f]X5pRLeUjUJCOlq$jeULdL!W<M,g3br2kh"OdG@%T4]FCQ4,*Wi&!b7Hp&be8dDQPZ;4&ZC`lrWVLJHr4]Jf'[<sO29N;UeR0os%ee4'NPRB!VD`!KG$G[2+@j$p&+7+8)(';$3`Re%JTK9UGp8+1oAV"]X(`XB0sD/_Z`Lq:DgD.HN97NFsI":8&E&_8%p'G#,hY&KQJ;G\ZW%F*Kn6!>0m\YonDIK.QrCP)&dd'>h:[/;hL95\F\29k6b%8@"cUo'Sr4a=X^4>AVM@t>NnStGe_L&$H5hm;j??;9[O1bXO%-dOPZr<1`_+%#9*gc_83A(k`,BP`q)U!3^H5lY(A'^73"<dJkjMLsS"!e1q</JK^H.'q!i\SB8Mrd:c`A#@7Us(+IZbe#9V0K*u(bhB]0DIb)%Sjf1S*2.GU>LQOt#cZ7r$ah-'bSn!\(UDW!Y(7PK39)S0=9jr/(BVCG[qZ!nb$>8`/On>1%[!2e>ITk[>KR:Jk2=i[,IW5<Mo5/jI;kM8U[ETp87nX<VW%p(*.a5a$i$-dh:c7jl=+UOBJ'[$@n=#O<h_Un1I\3+7+F!Z[G\T/:.WpnIA7^l3r?nDrQYl(^kX7ecG!&j2"l^LAs8QHH#*)`b$Za7[WZ5paeA4*i<]?ZeVJb"C"%$:`cNMB.V,r-n<g6bju5g3%5^n>I_4"Tf!#2S;mADl^f6Wc_YLUaZ;XX1Y8.+O<G\dTQdl>'J][\up]@]ja\1XXD4CE8D_GfN[XbemM;3_E2p*G49etYa'c[it%=l"H*I)BO>$`m@2]NSF"m9R*XT`&5=/]tFT2#ZK;'f,aOU;TEc7UaK5'o58r>fr,l,L.Q/Q[r$>9gP24VGFNeB[Ut7,BGN`%%Q@FJHY_dSSe>Yf#<^G<@X^@ebO_HIWbVCR&ghE<HJ[dF.`%/`G(d8%[-pEGj7&i7GiO((A>d_b/c79`LqA^'<JHIh><dTIP%:boFhdFEJG@:\7F:H.5-pG5UHhX<P\`7pY4AdH[b;5hcnWoTX4XESKTl9;4Ubq8OJ*H[aB&q8ATNPL*s$d'm.3aD(tFGLQ.^R_$XWNs,)pXc,69Z[EFolQM*h>AR'H'WDHM*lh"eGJ+VrS)ljU^"VN8\1Q#JrEANNh1`o)ima_RIXQh!Ve6gu)iY)T*!YF>$1ap.:YV2[HI5V?.SfU2]-Au*J:0jfDhf1lj5SS6#pW^M@#hJsdbTB)msC6X8X#5u[--H`Os_\>oVd6`rdr)X;i)#4V[dIXDA(\A7qpbIjh$dRd+\BDc!IJoiDb?l:pN=OZ8dCncOfGPo3&%YAD?#)khAiUT]69_G;ASI,l*l:fY%[LLj[hkc]EFE%,:;EIu+S<(WD$9Yt)"p=brMWb;HR\@c>s1T2MgsRFE`d(MUOrnFE;>L"s*fXmM<I[9]AVcRN.h_&J)C:jP<t?Qd`DHfn"<L->MERJ1;&gH^9J?dIS6\W4LBi;PAirY_%tQWC\7q,%;]X-a&:>*,RI&ZU,b_JkJIDMLoil=-\o'Dim,p^ER-KufK^8_<7F'Jn/Tb$'fkL4R?]-dIHYh%%G#8+D9R0@5uq&ZVH/X9'HV6%^Y&[!LoM9\U<mZP6Bup-1&U=?GA"X;c<Zk\0T%r]V=oK^VY53)'L&7Z<<?qYeG:3,Gq%!bKEpp3T)(\%Z4_^ZOE6eaG`rI(EZ[pT*h52;YNkqphoX`U)'2G$js+K?tB8]?u)nGCFTd3qsN^%hQk~>endstream
|
||||
Gau0CCK&tI'`H=\\0#F?(sB*B0`-F<Z=T@<>/K^O:1lW)<Y@9uj]W!7qs3uIc@]1m@G/bQ'1opLpZUCRY8I*10_[d^M;A\UpQuO#BK.1OGnCK]l.=nFpFS&j#H8f-(\FJiOdG'/31slr<T2b_5GA:!Z'6.mUUOI3JIc@Z(/o!fi$D$C(hLmmis9Q6BNW^VT;(:rL9_M86\"]2MMC=fOS"A^:LNs$MYS20I\Yk+CW).`+RrD$U*sP\T=d.>UVV!"[J;fSi]*SbYea]\G0@`M"L_L$+:@"PR$F6$hQlO41V%iP:uDlm>^S(/]JKpK4j7Vem5Z5qC<UGihOS6'R="?IBtSWPB0u8?Cu&MVE9A$>Dmo]*`RIR"2&f>788!nnQ(BD!GA*lL=R<i,P06HuA!1$A"=AAYPFdcS65u&N/F,c2Mc*MDH[!iKmr6M%%f6>M/sF?B!s#W?i!0c4B$&2*ju\nskOKe"c)eb4JKH.#J'_]LUN_]dCG6r2Pu`UkUIl-%lU#4=86(dc)V&N\,D/E0IP%gdEc--jo(*09fu>dRW%i"=obkTq2&9pOMddFNG2u/t5UCrbGV#tApd_Xt/'1Hf<T;$FBN>hCV%JrM\kn;<Dn;VK_O"0D@K^HAm9rc"TfL^]OuN)J.X33reQhFcmJYt2-PJ[e0Kn=b'0io^T@<KMf'h)H"mqK8L!Y)8/5m<35j*S%Xa>SF1]k_)=VUAX.#4M@"CAnD>`KQ`"c-3*FJOeGgG9!4.UH:.<m"6NR7ALPPT0VteqM.2NVi_@OO/]*>R4bSk$"4]GlcdC9HZI@3Xn-ek?>?gWCd_"V&:"_:.U`iGW.*_X_Cgg2nX.H>;`VbON>HD`1_60#8Q@s#e)BUB,Z:kc*&[7--mpD!luAX3c;m1PAP<,^*3adH/;cn%j/p;("lRA,FRS"Ufi4sAn%\#(nnO.fc1pP6J+)i>%U^\m,OXGks%kl#k#Ca\S1\-e^/`>)EL%Vki@1%_\Ms?$)Fo"]eGHEm]bC&2-A^)IAAX:b-,Vd)/jOT4DZteI\LSIJBV`aE#Zg9<7Y$iC?k";;Am>iO9->:TU,P"@OYO;!Vk"&DFX[CZCDX>f/ChG8N".!K:l5LYeHZ-ha@Ypj0R_O@?6T'jO8CDqZl:31_gBp>>I]U_4>B''$DU>q!SP"M6[?%h!ZP7lrg^,?u0^?0&dQbct6,_4oqi`gVbG0E$Ro<822JA=?J]*55okI3ILi7F"?M/[N;POp$c01[C[$-?BJt-GmPX$3Jk[(Uc*=sTb#RJhJfF2#+o+UFLrC(,+/q-`Uc^`$ZI'jK=#<']/Pp=FO&8d1pib,Zmkd8Yc<D-Z>$e3[gLT6WJg5=\'SKP,md*W\sI#^AF8)3N8nR1'+>ock\R5*:<=u+3jl;q*%5!kl2f>A!WkStRsNk5\Nia940@Aljl].S.mEFTo"#Y6N"PnE9*SP$8UYl(ZqhL@o%GAjI@hr5p?>s`K^F"Til2b'rFLl9"c&+J-A\#%&tbT659:h_pp]"DB91>Q]slj;QWp&-FNUf\d/'in"n>(\c9i(ori7%0M/,oAQHc.;,o%/mq=3=(QVtVJU8tbi)!5>geD:jbf^R`\`\'g6oAOH@)oP=:K]Jgin#*BP8C=,$X#K#c/;=ZehS2'&W>;RR@dDr#NSC[b2tDL$.h(7iMmX+&#Ug]cp(&=)'6s*M_sV>iBJkT^qtgH$Y2@dn(M])iZbEkg)j98p[U=<fq!c)i4Z$JQo<'(A(G+Cb+Z_qm);@-MMAKRML)./+F*I?Ap8;A1E+.-m=]Mo;^>jH)T:f/K/\=i468eJ*jLPF<`_O;l4-t8ALtajO>etd9c<_/6qJ:+Ge[ujj(YHBAfruJ"K_,03_^V:$6?21=s!aUJYj98]gKQ.b+B0(W_4#"8>cI16'<$p3n?Muo^),F<91,T+<!mi_S1&6#3^Z3VT-WXlq:YIKLM(7LSG`6Vkj64'aiF,/crbSAU/s;=Ac\?@!%Vq']q=Z?I`L?TXd-VI"$]Q0!7-J+IR9ZVgB((?[HNrM&[pR)\o?]!dCWofX];Fta50TN_s(bihMfIDd;Ul[Y"!E]Bhc2m)p:N\CT3k<^TD*3\IWQU$Ns]CQi~>endstream
|
||||
endobj
|
||||
59 0 obj
|
||||
<<
|
||||
|
|
@ -781,35 +781,35 @@ xref
|
|||
0005443418 00000 n
|
||||
0005443712 00000 n
|
||||
0005443952 00000 n
|
||||
0005446170 00000 n
|
||||
0005446937 00000 n
|
||||
0005447568 00000 n
|
||||
0005448168 00000 n
|
||||
0005448760 00000 n
|
||||
0005449360 00000 n
|
||||
0005449957 00000 n
|
||||
0005450542 00000 n
|
||||
0005451130 00000 n
|
||||
0005451748 00000 n
|
||||
0005452343 00000 n
|
||||
0005452930 00000 n
|
||||
0005453510 00000 n
|
||||
0005454082 00000 n
|
||||
0005454654 00000 n
|
||||
0005455247 00000 n
|
||||
0005455844 00000 n
|
||||
0005456414 00000 n
|
||||
0005456989 00000 n
|
||||
0005457585 00000 n
|
||||
0005458170 00000 n
|
||||
0005458738 00000 n
|
||||
0005459344 00000 n
|
||||
0005459924 00000 n
|
||||
0005460519 00000 n
|
||||
0005446153 00000 n
|
||||
0005446920 00000 n
|
||||
0005447551 00000 n
|
||||
0005448151 00000 n
|
||||
0005448743 00000 n
|
||||
0005449343 00000 n
|
||||
0005449940 00000 n
|
||||
0005450525 00000 n
|
||||
0005451113 00000 n
|
||||
0005451731 00000 n
|
||||
0005452326 00000 n
|
||||
0005452913 00000 n
|
||||
0005453493 00000 n
|
||||
0005454065 00000 n
|
||||
0005454637 00000 n
|
||||
0005455230 00000 n
|
||||
0005455827 00000 n
|
||||
0005456397 00000 n
|
||||
0005456972 00000 n
|
||||
0005457568 00000 n
|
||||
0005458153 00000 n
|
||||
0005458721 00000 n
|
||||
0005459327 00000 n
|
||||
0005459907 00000 n
|
||||
0005460502 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<2c41c692d1ec22d395f7c56711e5eca1><2c41c692d1ec22d395f7c56711e5eca1>]
|
||||
[<c5332a4dc2f7bc2ec6d1f9577b915862><c5332a4dc2f7bc2ec6d1f9577b915862>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 56 0 R
|
||||
|
|
@ -817,5 +817,5 @@ trailer
|
|||
/Size 84
|
||||
>>
|
||||
startxref
|
||||
5461285
|
||||
5461268
|
||||
%%EOF
|
||||
|
|
|
|||
|
|
@ -133,10 +133,10 @@ def main():
|
|||
|
||||
story.append(p("Issues still to consider", s["h1"]))
|
||||
remaining = [
|
||||
["Severity", "Issue", "Suggestion"],
|
||||
["Med", "Swagger /docs remains stock.", "Keep it for integrators; do not skin it. Portal already labels it stock OpenAPI explorer."],
|
||||
["Low", "access-staff and CS HTML are twins.", "Share one template if those packages ever merge."],
|
||||
["Low", "Staff cookie is host-scoped (127.0.0.1).", "Fine on one operator host. Multiple DNS names need a reverse proxy."],
|
||||
["Severity", "Issue", "What shipped"],
|
||||
["Med", "Swagger /docs remains stock (intentional).", "Left unskinned. Banner: OpenAPI explorer for integrators. Use x-api-key."],
|
||||
["Low", "access-staff and CS HTML were twins.", "Shared template package verae-staff-ui."],
|
||||
["Low", "Staff cookie was host-scoped.", "STAFF_COOKIE_DOMAIN plus reverse-proxy example in verae-staff-session."],
|
||||
]
|
||||
body = getSampleStyleSheet()["BodyText"]
|
||||
body.fontSize = 8
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import http from 'node:http';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { listCustomers, withCustomerName } from './names.js';
|
||||
import { staffPageHtml } from './staff-page.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
const PORT = Number(process.env.PORT || 3025);
|
||||
|
|
@ -41,7 +42,16 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
res.end(
|
||||
await staffPageHtml(
|
||||
{
|
||||
title: 'Staff access',
|
||||
kicker: 'staff plane · after authz',
|
||||
lede: 'CS / sales / accounting door. Review and credit go through authz, then account-balance. Amounts are dollars.',
|
||||
},
|
||||
path.join(PUBLIC, 'index.html'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
|
|
|
|||
16
packages/verae-access-staff/src/staff-page.js
Normal file
16
packages/verae-access-staff/src/staff-page.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export async function staffPageHtml(opts, fallbackPath) {
|
||||
const sibling = path.join(path.dirname(fallbackPath), '..', '..', 'verae-staff-ui', 'src', 'load.js');
|
||||
try {
|
||||
if (fs.existsSync(sibling)) {
|
||||
const { loadReviewHtml } = await import(pathToFileURL(sibling).href);
|
||||
return loadReviewHtml(opts, fallbackPath);
|
||||
}
|
||||
} catch {
|
||||
/* standalone clone */
|
||||
}
|
||||
return fs.readFileSync(fallbackPath, 'utf8');
|
||||
}
|
||||
|
|
@ -7,3 +7,25 @@ Shared cookie login for CS / sales / accounting / access-staff HTML.
|
|||
Port `:3027`. Set `STAFF_AUTH=1` on the department servers and `STAFF_SESSION_URL=http://127.0.0.1:3027`. Cookie host is the browser host (ports share `127.0.0.1`). JSON APIs stay open unless you also send `x-staff-key`.
|
||||
|
||||
Default key: `STAFF_KEY` or `ADMIN_KEY` or `admin-dev-key`.
|
||||
|
||||
## Multiple hostnames
|
||||
|
||||
Cookies are host-scoped. On one operator box (`127.0.0.1`) that is enough. For several DNS names, put one reverse proxy in front and set `STAFF_COOKIE_DOMAIN`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name staff.example.com;
|
||||
location /session/ { proxy_pass http://127.0.0.1:3027/; }
|
||||
location /cs/ { proxy_pass http://127.0.0.1:3011/; }
|
||||
location /sales/ { proxy_pass http://127.0.0.1:3012/; }
|
||||
location /acct/ { proxy_pass http://127.0.0.1:3013/; }
|
||||
location /staff/ { proxy_pass http://127.0.0.1:3025/; }
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
STAFF_COOKIE_DOMAIN=.example.com
|
||||
STAFF_COOKIE_SECURE=1
|
||||
STAFF_SESSION_URL=https://staff.example.com/session
|
||||
STAFF_AUTH=1
|
||||
```
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export function sessionToken() {
|
|||
}
|
||||
|
||||
export function cookieHeader() {
|
||||
return `staff_session=${sessionToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`;
|
||||
let s = `staff_session=${sessionToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`;
|
||||
const domain = process.env.STAFF_COOKIE_DOMAIN;
|
||||
if (domain) s += `; Domain=${domain}`;
|
||||
if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure';
|
||||
return s;
|
||||
}
|
||||
|
||||
export function cookieOk(req) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sessionToken, cookieOk, headerOk } from '../src/token.js';
|
||||
import { sessionToken, cookieOk, headerOk, cookieHeader } from '../src/token.js';
|
||||
|
||||
test('cookie matches HMAC of staff key', () => {
|
||||
const tok = sessionToken();
|
||||
|
|
@ -9,3 +9,11 @@ test('cookie matches HMAC of staff key', () => {
|
|||
assert.equal(cookieOk({ headers: { cookie: 'staff_session=nope' } }), false);
|
||||
assert.equal(headerOk({ headers: { 'x-staff-key': process.env.STAFF_KEY || 'admin-dev-key' } }), true);
|
||||
});
|
||||
|
||||
test('cookie Domain is optional', () => {
|
||||
delete process.env.STAFF_COOKIE_DOMAIN;
|
||||
assert.equal(cookieHeader().includes('Domain='), false);
|
||||
process.env.STAFF_COOKIE_DOMAIN = '.example.com';
|
||||
assert.match(cookieHeader(), /Domain=\.example.com/);
|
||||
delete process.env.STAFF_COOKIE_DOMAIN;
|
||||
});
|
||||
|
|
|
|||
3
packages/verae-staff-ui/NATS.md
Normal file
3
packages/verae-staff-ui/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS
|
||||
|
||||
None. Static HTML template.
|
||||
7
packages/verae-staff-ui/README.md
Normal file
7
packages/verae-staff-ui/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# verae-staff-ui
|
||||
|
||||
One review HTML template for **customer-service** and **access-staff** (they were twins). Tokens: `{{TITLE}}`, `{{KICKER}}`, `{{LEDE}}`.
|
||||
|
||||
In the monorepo both servers load `packages/verae-staff-ui/public/review.html`. A cloned package can set `STAFF_UI_HTML` or keep a local `public/index.html` fallback.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-ui
|
||||
3
packages/verae-staff-ui/SUMMARY.md
Normal file
3
packages/verae-staff-ui/SUMMARY.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# verae-staff-ui
|
||||
|
||||
Shared staff account-review page (dollars, names typeahead, credit form) used by CS and the staff access plane.
|
||||
8
packages/verae-staff-ui/package.json
Normal file
8
packages/verae-staff-ui/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "verae-staff-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Shared staff review HTML for CS and access-staff",
|
||||
"scripts": { "test": "node --test test/*.test.js" }
|
||||
}
|
||||
157
packages/verae-staff-ui/public/review.html
Normal file
157
packages/verae-staff-ui/public/review.html
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
|
||||
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
|
||||
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
|
||||
.skip { position:absolute; left:-999px; }
|
||||
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
|
||||
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
|
||||
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
|
||||
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
|
||||
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
|
||||
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
|
||||
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
|
||||
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
|
||||
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
|
||||
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
|
||||
.row > div { min-width:10rem; }
|
||||
button, a.btn {
|
||||
border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer;
|
||||
background:var(--accent); color:#fff; text-decoration:none; display:inline-block;
|
||||
}
|
||||
button.ghost, a.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
|
||||
table { border-collapse:collapse; width:100%; }
|
||||
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
|
||||
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
|
||||
.money { font-variant-numeric:tabular-nums; }
|
||||
.muted { color:var(--muted); }
|
||||
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
|
||||
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
|
||||
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
|
||||
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
|
||||
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
|
||||
.empty svg { display:block; margin:0 auto .6rem; }
|
||||
.err { color:var(--danger); }
|
||||
.who { font-weight:750; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="app">
|
||||
<div class="kicker">{{KICKER}}</div>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<p>{{LEDE}}</p>
|
||||
</header>
|
||||
<main id="main">
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="id">Customer</label>
|
||||
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
|
||||
<datalist id="cust-list"></datalist>
|
||||
</div>
|
||||
<button type="button" onclick="review()">Review account</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Apply credit</h3>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="dollars">Amount (USD)</label>
|
||||
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="reason">Reason</label>
|
||||
<input id="reason" value="goodwill" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="agent">Agent name</label>
|
||||
<input id="agent" value="cs" />
|
||||
</div>
|
||||
<button type="button" onclick="credit()">Apply credit</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:.7rem 0 0;font-size:12px">Credits write to account-balance. The customer sees dollars, not cents.</p>
|
||||
</div>
|
||||
<div id="out"></div>
|
||||
</main>
|
||||
<script>
|
||||
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
|
||||
const empty = (title, hint) =>
|
||||
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div class="who">${title}</div><p>${hint}</p></div>`;
|
||||
function moneyTable(list, cols, emptyTitle) {
|
||||
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
|
||||
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
|
||||
const body = list.map((r) => '<tr>' + cols.map((c) => {
|
||||
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
|
||||
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
|
||||
}).join('') + '</tr>').join('');
|
||||
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
function paint(st, id) {
|
||||
const name = st.name || st.customerName || id;
|
||||
document.getElementById('out').innerHTML = `
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
|
||||
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
|
||||
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
|
||||
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
|
||||
</div>
|
||||
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
|
||||
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
|
||||
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
|
||||
}
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const { customers } = await (await fetch('/customers')).json();
|
||||
document.getElementById('cust-list').innerHTML = (customers || [])
|
||||
.map((c) => `<option value="${c.name}">${c.id}</option>`)
|
||||
.join('');
|
||||
if (customers && customers[0] && !document.getElementById('id').value) {
|
||||
document.getElementById('id').value = customers[0].name;
|
||||
}
|
||||
} catch { /* edge optional */ }
|
||||
}
|
||||
loadCustomers();
|
||||
async function review() {
|
||||
const id = document.getElementById('id').value.trim();
|
||||
try {
|
||||
const r = await fetch('/review/' + encodeURIComponent(id));
|
||||
const st = await r.json();
|
||||
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
|
||||
paint(st, id);
|
||||
} catch (err) {
|
||||
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
async function credit() {
|
||||
const dollars = Number(document.getElementById('dollars').value);
|
||||
const body = {
|
||||
customerId: document.getElementById('id').value.trim(),
|
||||
cents: Math.round(dollars * 100),
|
||||
reason: document.getElementById('reason').value,
|
||||
agent: document.getElementById('agent').value,
|
||||
};
|
||||
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) {
|
||||
const st = await r.json().catch(() => ({}));
|
||||
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
|
||||
return;
|
||||
}
|
||||
await review();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
27
packages/verae-staff-ui/src/load.js
Normal file
27
packages/verae-staff-ui/src/load.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function reviewHtmlPath(fallback) {
|
||||
const env = process.env.STAFF_UI_HTML;
|
||||
const shared = path.join(HERE, '..', 'public', 'review.html');
|
||||
const sibling = path.join(HERE, '..', '..', 'verae-staff-ui', 'public', 'review.html');
|
||||
for (const p of [env, shared, sibling, fallback]) {
|
||||
if (p && fs.existsSync(p)) return p;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function loadReviewHtml(opts, fallback) {
|
||||
const file = reviewHtmlPath(fallback);
|
||||
let html = fs.readFileSync(file, 'utf8');
|
||||
const map = {
|
||||
'{{TITLE}}': opts.title || 'Staff review',
|
||||
'{{KICKER}}': opts.kicker || 'staff',
|
||||
'{{LEDE}}': opts.lede || 'Review prepaid balance, credits, usage, and payments in dollars.',
|
||||
};
|
||||
for (const [k, v] of Object.entries(map)) html = html.split(k).join(v);
|
||||
return html;
|
||||
}
|
||||
11
packages/verae-staff-ui/test/load.test.js
Normal file
11
packages/verae-staff-ui/test/load.test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadReviewHtml } from '../src/load.js';
|
||||
|
||||
test('fills title kicker lede', () => {
|
||||
const html = loadReviewHtml({ title: 'Staff access', kicker: 'staff plane', lede: 'After authz.' });
|
||||
assert.match(html, /Staff access/);
|
||||
assert.match(html, /staff plane/);
|
||||
assert.match(html, /After authz/);
|
||||
assert.doesNotMatch(html, /\{\{TITLE\}\}/);
|
||||
});
|
||||
|
|
@ -32,6 +32,12 @@
|
|||
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
|
||||
- SSH spawn timeout 8s; failed hosts skipped.
|
||||
|
||||
## 2026-09-11 — last three UI leftovers
|
||||
|
||||
- Swagger `/docs` stays stock; banner names it OpenAPI explorer.
|
||||
- Shared template `verae-staff-ui` for CS + access-staff.
|
||||
- `STAFF_COOKIE_DOMAIN` + nginx example for multi-host.
|
||||
|
||||
## 2026-09-11 — names, staff session, exclusive jobs.events
|
||||
|
||||
- Account-balance stores display names (`customer.put` + lookup by name). Edge writes names on customer create/edit; CS/sales/accounting/staff join from edge if the ledger has no name.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import path from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
import { listCustomers, withCustomerName } from './names.js';
|
||||
import { staffPageHtml } from './staff-page.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
|
|
@ -44,7 +45,16 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
res.end(
|
||||
await staffPageHtml(
|
||||
{
|
||||
title: 'Customer service',
|
||||
kicker: 'staff · customer service',
|
||||
lede: 'Look up a customer by name. Review prepaid balance, credits, usage, and payments. Credit amounts are in dollars.',
|
||||
},
|
||||
path.join(PUBLIC, 'index.html'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
|
|
|
|||
16
packages/zappier-customer-service/src/staff-page.js
Normal file
16
packages/zappier-customer-service/src/staff-page.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export async function staffPageHtml(opts, fallbackPath) {
|
||||
const sibling = path.join(path.dirname(fallbackPath), '..', '..', 'verae-staff-ui', 'src', 'load.js');
|
||||
try {
|
||||
if (fs.existsSync(sibling)) {
|
||||
const { loadReviewHtml } = await import(pathToFileURL(sibling).href);
|
||||
return loadReviewHtml(opts, fallbackPath);
|
||||
}
|
||||
} catch {
|
||||
/* standalone clone */
|
||||
}
|
||||
return fs.readFileSync(fallbackPath, 'utf8');
|
||||
}
|
||||
|
|
@ -120,7 +120,16 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
app.use(express.json());
|
||||
|
||||
const spec = YAML.load(SPEC_PATH);
|
||||
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
|
||||
app.use(
|
||||
'/docs',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(spec, {
|
||||
customSiteTitle: 'OpenAPI explorer (stock Swagger UI)',
|
||||
customCss:
|
||||
'body::before{content:"OpenAPI explorer — stock Swagger UI for integrators. Not a customer surface. Use x-api-key.";' +
|
||||
'display:block;background:#312e81;color:#eef0fe;font:650 13px -apple-system,sans-serif;padding:.7rem 1.2rem}',
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(
|
||||
'/admin/api',
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ def main() -> None:
|
|||
"zapier-decisions",
|
||||
"ui-docs",
|
||||
"verae-staff-session",
|
||||
"verae-staff-ui",
|
||||
):
|
||||
pkg_root = ROOT / "packages" / pkg
|
||||
if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}:
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ REPO_READMES = [
|
|||
"zapier-decisions",
|
||||
"ui-docs",
|
||||
"verae-staff-session",
|
||||
"verae-staff-ui",
|
||||
]
|
||||
|
||||
SKIP_PARTS = {"test", "tests", "node_modules", "dist"}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ create verae-nats-accounts "NATS INTERNAL vs LEAF account policy"
|
|||
create zapier-decisions "Architecture decisions and action log"
|
||||
create UI-Docs "UI walkthrough, screenshots, and review PDF"
|
||||
create verae-staff-session "Shared staff cookie login for department HTML"
|
||||
create verae-staff-ui "Shared staff review HTML template"
|
||||
|
||||
push_dir "$ROOT/packages/zappier" zappier-edge
|
||||
push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware
|
||||
|
|
@ -98,5 +99,6 @@ push_dir "$ROOT/packages/verae-nats-accounts" verae-nats-accounts
|
|||
push_dir "$ROOT/packages/zapier-decisions" zapier-decisions
|
||||
push_dir "$ROOT/packages/ui-docs" UI-Docs
|
||||
push_dir "$ROOT/packages/verae-staff-session" verae-staff-session
|
||||
push_dir "$ROOT/packages/verae-staff-ui" verae-staff-ui
|
||||
|
||||
echo ALL_MODULE_REPOS_PUSHED
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue