diff --git a/apps/super-admin/src/components/Toast.module.css b/apps/super-admin/src/components/Toast.module.css index f3b1817..130eaae 100644 --- a/apps/super-admin/src/components/Toast.module.css +++ b/apps/super-admin/src/components/Toast.module.css @@ -2,7 +2,8 @@ position: fixed; bottom: 24px; right: 24px; - z-index: 300; + /* Above Modal overlay (z-index 300) so success/error feedback is visible while dialogs are open */ + z-index: 400; display: flex; flex-direction: column; align-items: flex-end; diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx index a92293e..2a24042 100644 --- a/apps/super-admin/src/pages/BusinessesPage.tsx +++ b/apps/super-admin/src/pages/BusinessesPage.tsx @@ -692,23 +692,39 @@ export function BusinessesPage() { } } + const domainSubmitGenRef = useRef(0) + + function closeDomainModal() { + domainSubmitGenRef.current += 1 + setDomainSubmitting(false) + setDomainOpen(false) + setDomainBusiness(null) + setDomainId(null) + setDomainHost('') + setDomainGitRepoUrl('') + setDomainError('') + } + async function submitDomain() { if (!domainBusiness) return const host = domainHost.trim() if (!host) return const gitRepoUrl = domainGitRepoUrl.trim() + const businessId = domainBusiness.id + const submitId = ++domainSubmitGenRef.current setDomainSubmitting(true) setDomainError('') try { if (domainId) { - const updated = (await updateBusinessDomain(domainBusiness.id, domainId, { + const updated = (await updateBusinessDomain(businessId, domainId, { host, ...(gitRepoUrl ? { gitRepoUrl } : {}), })) as { id: number host: string deploySlug?: string | null + gitRepoUrl?: string | null provisionError?: string | null } setData((prev) => { @@ -716,7 +732,18 @@ export function BusinessesPage() { return { ...prev, items: prev.items.map((item) => - item.id === domainBusiness.id ? { ...item, domain: host } : item, + item.id === businessId + ? { + ...item, + domain: host, + ...(gitRepoUrl && !updated.provisionError + ? { + gitRepoUrl: updated.gitRepoUrl ?? gitRepoUrl, + deploySlug: updated.deploySlug ?? item.deploySlug, + } + : {}), + } + : item, ), } }) @@ -732,7 +759,7 @@ export function BusinessesPage() { showToast(`Domain updated to "${host}".`, 'success') } } else { - const created = (await addBusinessDomain(domainBusiness.id, { + const created = (await addBusinessDomain(businessId, { host, isPrimary: true, ...(gitRepoUrl ? { gitRepoUrl } : {}), @@ -741,6 +768,7 @@ export function BusinessesPage() { host: string sslEnabled: boolean deploySlug?: string | null + gitRepoUrl?: string | null provisionError?: string | null } setData((prev) => { @@ -748,12 +776,18 @@ export function BusinessesPage() { return { ...prev, items: prev.items.map((item) => - item.id === domainBusiness.id + item.id === businessId ? { ...item, domainId: created.id, domain: created.host, sslEnabled: created.sslEnabled ?? false, + ...(gitRepoUrl && !created.provisionError + ? { + gitRepoUrl: created.gitRepoUrl ?? gitRepoUrl, + deploySlug: created.deploySlug ?? null, + } + : {}), } : item, ), @@ -771,14 +805,20 @@ export function BusinessesPage() { showToast(`Domain "${host}" added.`, 'success') } } + if (submitId !== domainSubmitGenRef.current) return setDomainOpen(false) setDomainBusiness(null) setDomainId(null) + setDomainHost('') setDomainGitRepoUrl('') + setDomainError('') } catch (err) { + if (submitId !== domainSubmitGenRef.current) return setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.') } finally { - setDomainSubmitting(false) + if (submitId === domainSubmitGenRef.current) { + setDomainSubmitting(false) + } } } @@ -1270,19 +1310,18 @@ export function BusinessesPage() { { - setDomainOpen(false) - setDomainBusiness(null) - setDomainId(null) - setDomainGitRepoUrl('') - setDomainError('') - }} + onClose={closeDomainModal} > {domainError ? ( {domainError} ) : null} + {domainSubmitting && domainGitRepoUrl.trim() ? ( + + Saving domain and setting up storefront on the websites server… + + ) : null} Domain setDomainGitRepoUrl(e.target.value)} - placeholder="https://git.meshkee.com/Meshkee-Websites/oaktasty.git" + placeholder="https://git.meshkee.com/Meshkee-Websites/oaktasty.git" disabled={domainSubmitting} autoComplete="off" spellCheck={false} @@ -1315,8 +1354,7 @@ export function BusinessesPage() { setDomainOpen(false)} - disabled={domainSubmitting} + onClick={closeDomainModal} > Cancel @@ -1326,7 +1364,7 @@ export function BusinessesPage() { onClick={() => void submitDomain()} disabled={domainSubmitting || !domainHost.trim()} > - Save + {domainSubmitting ? 'Saving…' : 'Save'} diff --git a/apps/super-admin/src/pages/WebsitesPage.tsx b/apps/super-admin/src/pages/WebsitesPage.tsx index 91da1d2..213b9b9 100644 --- a/apps/super-admin/src/pages/WebsitesPage.tsx +++ b/apps/super-admin/src/pages/WebsitesPage.tsx @@ -10,7 +10,6 @@ import { RotateCcw, Search, ShieldPlus, - Unlock, } from 'lucide-react' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu' @@ -26,7 +25,6 @@ import { listDomains, removeDomain, setDomainActive, - setDomainSsl, syncSsl, updateDomain, } from '../services/domainService' @@ -105,7 +103,6 @@ export function WebsitesPage() { const [removeTarget, setRemoveTarget] = useState(null) const [togglingActiveId, setTogglingActiveId] = useState(null) - const [togglingSslId, setTogglingSslId] = useState(null) const [deployingId, setDeployingId] = useState(null) const [syncingSsl, setSyncingSsl] = useState(false) const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false) @@ -236,38 +233,6 @@ export function WebsitesPage() { } } - async function handleToggleSsl(domain: DomainListItem, sslEnabled: boolean) { - setTogglingSslId(domain.id) - setError('') - setData((prev) => { - if (!prev) return prev - return { - ...prev, - items: prev.items.map((item) => (item.id === domain.id ? { ...item, sslEnabled } : item)), - } - }) - try { - await setDomainSsl(domain.id, sslEnabled) - showToast( - `SSL ${sslEnabled ? 'enabled' : 'disabled'} for "${domain.host}".`, - 'success', - ) - } catch (err) { - setData((prev) => { - if (!prev) return prev - return { - ...prev, - items: prev.items.map((item) => - item.id === domain.id ? { ...item, sslEnabled: !sslEnabled } : item, - ), - } - }) - setError(err instanceof ApiError ? err.message : 'Unable to update SSL status.') - } finally { - setTogglingSslId(null) - } - } - async function handleSyncSsl() { if (syncingSsl || issuingWebsiteSsl) return @@ -309,18 +274,9 @@ export function WebsitesPage() { } else { showToast(result.message || 'Website SSL updated.', 'success') } - if (result.issued.length > 0) { - setData((prev) => { - if (!prev) return prev - const issued = new Set(result.issued) - return { - ...prev, - items: prev.items.map((item) => - issued.has(item.host) ? { ...item, sslEnabled: true } : item, - ), - } - }) - } + // Re-load so ssl_enabled matches live probes (including corrected false flags). + const refreshed = await listDomains({ page, pageSize: PAGE_SIZE, ...appliedFilters }) + setData(refreshed) } catch (err) { const message = err instanceof ApiError ? err.message : 'Unable to issue website SSL.' setError(message) @@ -331,13 +287,16 @@ export function WebsitesPage() { } async function handleIssueDomainSsl(domain: DomainListItem) { - if (!domain.deploySlug || issuingSslId != null) return + if (issuingSslId != null) return flushSync(() => { setIssuingSslId(domain.id) }) setError('') - showToast(`Issuing SSL for "${domain.host}"…`, 'info') + showToast( + `Checking SSL for ${domain.host}, business.${domain.host}, customer.${domain.host}…`, + 'info', + ) try { const result = await issueDomainSsl(domain.id) @@ -350,9 +309,15 @@ export function WebsitesPage() { ), } }) - showToast(result.message || `SSL issued for "${domain.host}".`, 'success') + if (result.failed.length > 0) { + const detail = result.failed.map((f) => `${f.host}: ${f.error}`).join(' · ') + setError(detail) + showToast(result.message, result.issued.length > 0 ? 'info' : 'error') + } else { + showToast(result.message || `SSL ok for "${domain.host}".`, 'success') + } } catch (err) { - const message = err instanceof ApiError ? err.message : 'Unable to issue SSL.' + const message = err instanceof ApiError ? err.message : 'Unable to ensure SSL.' setError(message) showToast(message, 'error') } finally { @@ -468,7 +433,7 @@ export function WebsitesPage() { onClick={() => void handleIssueWebsiteSsl()} disabled={issuingWebsiteSsl || syncingSsl} aria-busy={issuingWebsiteSsl} - title="Issue Let's Encrypt certs for storefront domains missing SSL" + title="Probe storefront HTTPS and issue Let's Encrypt where the cert is missing or wrong" > {issuingWebsiteSsl ? ( @@ -665,46 +630,31 @@ export function WebsitesPage() { )} ) : null} - {domain.deploySlug && !domain.sslEnabled ? ( - void handleIssueDomainSsl(domain)} - disabled={issuingSslId === domain.id || issuingWebsiteSsl} - title={ - issuingSslId === domain.id - ? 'Issuing SSL…' - : 'Issue SSL certificate' - } - aria-label={ - issuingSslId === domain.id - ? 'Issuing SSL' - : 'Issue SSL certificate' - } - aria-busy={issuingSslId === domain.id} - > - {issuingSslId === domain.id ? ( - - ) : ( - - )} - - ) : ( - void handleToggleSsl(domain, !domain.sslEnabled)} - disabled={togglingSslId === domain.id} - title={domain.sslEnabled ? 'Mark SSL invalid' : 'Mark SSL valid'} - aria-label={ - domain.sslEnabled ? 'Mark SSL invalid' : 'Mark SSL valid' - } - > - {domain.sslEnabled ? : } - - )} + void handleIssueDomainSsl(domain)} + disabled={issuingSslId === domain.id || issuingWebsiteSsl || syncingSsl} + title={ + issuingSslId === domain.id + ? 'Checking / issuing SSL…' + : 'Check & issue SSL for domain, business.*, customer.*' + } + aria-label={ + issuingSslId === domain.id + ? 'Ensuring SSL' + : 'Check and issue SSL' + } + aria-busy={issuingSslId === domain.id} + > + {issuingSslId === domain.id ? ( + + ) : ( + + )} + (`/domains/${domainId}/issue-ssl`, { method: 'POST', diff --git a/apps/super-admin/src/types/domain.ts b/apps/super-admin/src/types/domain.ts index 7e7fc83..e3ced29 100644 --- a/apps/super-admin/src/types/domain.ts +++ b/apps/super-admin/src/types/domain.ts @@ -43,9 +43,22 @@ export interface IssueWebsiteSslResponse { failed: Array<{ host: string; error: string }> } +export interface IssueDomainSslHostResult { + host: string + status: 'ok' | 'issued' | 'failed' | 'skipped' + detail?: string +} + export interface IssueDomainSslResponse { - status: 'issued' + status: 'ok' | 'partial' host: string sslEnabled: boolean + hosts: { + apex: IssueDomainSslHostResult + business: IssueDomainSslHostResult + customer: IssueDomainSslHostResult + } + issued: string[] + failed: Array<{ host: string; error: string }> message: string } diff --git a/deploy/ssl-sync-agent.mjs b/deploy/ssl-sync-agent.mjs index 277b4e2..6673eb9 100644 --- a/deploy/ssl-sync-agent.mjs +++ b/deploy/ssl-sync-agent.mjs @@ -1,13 +1,18 @@ #!/usr/bin/env node /** * Tiny HTTP agent on the dashboards VPS. - * Super Admin → Nest API → POST here → runs ssl-sync.sh in the background. + * Super Admin → Nest API → POST here → runs ssl-sync.sh * * Env (/etc/meshkee/ssl-sync-agent.env): * SSL_SYNC_AGENT_TOKEN=... * SSL_SYNC_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-sync.sh * PORT=9051 * BIND=0.0.0.0 + * + * POST /ssl-sync + * Body (optional JSON): { "wait": true } + * wait=false (default): accept and run in background → 202 + * wait=true: run script and wait for exit → 200 / 500 */ import { createServer } from 'node:http' import { spawn } from 'node:child_process' @@ -42,7 +47,24 @@ function json(res, status, body) { res.end(payload) } -function startSync() { +function readJson(req) { + return new Promise((resolve, reject) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim() + if (!raw) return resolve({}) + try { + resolve(JSON.parse(raw)) + } catch (err) { + reject(err) + } + }) + req.on('error', reject) + }) +} + +function startSyncDetached() { running = true const child = spawn(SCRIPT, [], { detached: true, @@ -62,7 +84,45 @@ function startSync() { child.unref() } -const server = createServer((req, res) => { +function runSyncAndWait() { + return new Promise((resolve) => { + running = true + const child = spawn(SCRIPT, [], { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (d) => { + stdout += d.toString() + }) + child.stderr.on('data', (d) => { + stderr += d.toString() + }) + child.on('error', (err) => { + running = false + resolve({ + ok: false, + code: 1, + log: err.message, + }) + }) + child.on('exit', (code, signal) => { + running = false + const log = `${stdout}${stderr}`.trim().slice(-4000) + console.log( + `${new Date().toISOString()} ssl-sync waited code=${code} signal=${signal ?? ''}`, + ) + resolve({ + ok: code === 0, + code: code ?? 1, + log, + }) + }) + }) +} + +const server = createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/health') { return json(res, 200, { ok: true, running }) } @@ -80,7 +140,34 @@ const server = createServer((req, res) => { return json(res, 409, { error: 'ssl sync already running' }) } - startSync() + let body = {} + try { + body = await readJson(req) + } catch { + return json(res, 400, { error: 'invalid json' }) + } + + const wait = body && body.wait === true + + if (wait) { + console.log(`${new Date().toISOString()} ssl-sync wait start`) + const result = await runSyncAndWait() + if (!result.ok) { + return json(res, 500, { + status: 'failed', + message: 'SSL sync script failed', + code: result.code, + log: result.log, + }) + } + return json(res, 200, { + status: 'ok', + message: 'SSL sync completed', + log: result.log, + }) + } + + startSyncDetached() console.log(`${new Date().toISOString()} ssl-sync accepted`) return json(res, 202, { status: 'accepted', diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ffc2376..76e6309 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -58,6 +58,10 @@ SSL_SYNC_AGENT_TOKEN= Endpoint used by the UI: `POST /api/v1/domains/ssl-sync` (super-admin JWT). +Body on the agent (optional): `{ "wait": true }` — run `ssl-sync.sh` and wait for exit (used by per-row Ensure SSL). Default is fire-and-forget `202`. + +Per-row Ensure SSL: `POST /api/v1/domains/:id/issue-ssl` probes `host`, `business.host`, `customer.host` and issues only failures. + ## Redeploy frontends From your laptop (rsync source tree, then build on server): diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index a017983..fac6bb0 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -514,7 +514,7 @@ Migration: `database/migrations/033_business_favicon.sql` | API | `https://api.meshkee.com/api/v1` (host `185.164.72.119`) | | Docs | `docs/DEPLOY.md` | -SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h + Super Admin **Sync dashboard SSL** (`POST /domains/ssl-sync` → dashboards agent). Storefront SSL: **Issue website SSL** (`POST /domains/website-ssl`) + per-row issue (`POST /domains/:id/issue-ssl`) → websites agent `/ssl` (certbot). +SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h + Super Admin **Sync dashboard SSL** (`POST /domains/ssl-sync` → dashboards agent). Storefront SSL: **Issue website SSL** (`POST /domains/website-ssl`) → websites agent `/ssl`. Per-row lock: **Ensure SSL** (`POST /domains/:id/issue-ssl`) probes apex + `business.*` + `customer.*`, issues only what’s missing (websites agent for apex when `deploy_slug` set; blocking dashboard ssl-sync when business/customer need certs). ---
{domainError}
+ Saving domain and setting up storefront on the websites server… +