showpane 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/bundle/meta/scaffold-manifest.json +73 -0
- package/bundle/scaffold/VERSION +1 -0
- package/bundle/scaffold/__dot__env.example +24 -0
- package/bundle/scaffold/__dot__gitignore +41 -0
- package/bundle/scaffold/docker/Caddyfile +3 -0
- package/bundle/scaffold/docker/Dockerfile +30 -0
- package/bundle/scaffold/docker-compose.yml +53 -0
- package/bundle/scaffold/next.config.ts +20 -0
- package/bundle/scaffold/package-lock.json +5843 -0
- package/bundle/scaffold/package.json +42 -0
- package/bundle/scaffold/postcss.config.js +6 -0
- package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
- package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
- package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
- package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
- package/bundle/scaffold/prisma/schema.local.prisma +131 -0
- package/bundle/scaffold/prisma/schema.prisma +128 -0
- package/bundle/scaffold/prisma/seed.ts +49 -0
- package/bundle/scaffold/public/example-avatar.svg +4 -0
- package/bundle/scaffold/public/example-logo.svg +4 -0
- package/bundle/scaffold/public/robots.txt +2 -0
- package/bundle/scaffold/scripts/backup.sh +19 -0
- package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
- package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
- package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
- package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
- package/bundle/scaffold/scripts/restore.sh +31 -0
- package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
- package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
- package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
- package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
- package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
- package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
- package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
- package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
- package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
- package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
- package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
- package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
- package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
- package/bundle/scaffold/src/app/api/health/route.ts +19 -0
- package/bundle/scaffold/src/app/globals.css +7 -0
- package/bundle/scaffold/src/app/layout.tsx +25 -0
- package/bundle/scaffold/src/app/page.tsx +177 -0
- package/bundle/scaffold/src/components/portal-login.tsx +169 -0
- package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
- package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
- package/bundle/scaffold/src/lib/branding.ts +50 -0
- package/bundle/scaffold/src/lib/client-auth.ts +98 -0
- package/bundle/scaffold/src/lib/client-portals.ts +134 -0
- package/bundle/scaffold/src/lib/control-plane.ts +100 -0
- package/bundle/scaffold/src/lib/db.ts +7 -0
- package/bundle/scaffold/src/lib/files.ts +124 -0
- package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
- package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
- package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
- package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
- package/bundle/scaffold/src/lib/storage.ts +204 -0
- package/bundle/scaffold/src/lib/token.ts +186 -0
- package/bundle/scaffold/src/lib/utils.ts +6 -0
- package/bundle/scaffold/src/middleware.ts +61 -0
- package/bundle/scaffold/tailwind.config.ts +15 -0
- package/bundle/scaffold/tests/__dot__gitkeep +0 -0
- package/bundle/scaffold/tsconfig.json +23 -0
- package/bundle/scaffold/vitest.config.ts +13 -0
- package/bundle/toolchain/VERSION +1 -0
- package/bundle/toolchain/bin/check-slug.ts +59 -0
- package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
- package/bundle/toolchain/bin/create-portal.ts +71 -0
- package/bundle/toolchain/bin/delete-portal.ts +48 -0
- package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
- package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
- package/bundle/toolchain/bin/generate-share-link.ts +68 -0
- package/bundle/toolchain/bin/list-portals.ts +53 -0
- package/bundle/toolchain/bin/materialize-file.ts +35 -0
- package/bundle/toolchain/bin/query-analytics.ts +88 -0
- package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
- package/bundle/toolchain/bin/showpane-config +63 -0
- package/bundle/toolchain/bin/tsconfig.json +13 -0
- package/bundle/toolchain/skills/VERSION +1 -0
- package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
- package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
- package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
- package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
- package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
- package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
- package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
- package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
- package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
- package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
- package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
- package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
- package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
- package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
- package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
- package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
- package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
- package/bundle/toolchain/skills/shared/preamble.md +137 -0
- package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
- package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
- package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
- package/dist/index.js +1248 -164
- package/package.json +3 -2
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Check, Copy, type LucideIcon } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import {
|
|
7
|
+
ANALYTICS_METADATA_KEYS,
|
|
8
|
+
type PortalEventMetadata,
|
|
9
|
+
type PortalEventType,
|
|
10
|
+
} from "@/lib/portal-contracts";
|
|
11
|
+
|
|
12
|
+
export type PortalTab = {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon: LucideIcon;
|
|
16
|
+
badge?: "amber" | null;
|
|
17
|
+
content: ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PortalContact = {
|
|
21
|
+
name: string;
|
|
22
|
+
title: string;
|
|
23
|
+
avatarSrc: string;
|
|
24
|
+
email: string;
|
|
25
|
+
phone?: string;
|
|
26
|
+
phoneDisplay?: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PortalShellProps = {
|
|
31
|
+
companyName: string;
|
|
32
|
+
companyLogo: ReactNode;
|
|
33
|
+
portalLabel?: string;
|
|
34
|
+
|
|
35
|
+
clientName: string;
|
|
36
|
+
clientLogoSrc: string;
|
|
37
|
+
clientLogoAlt: string;
|
|
38
|
+
|
|
39
|
+
tabs: PortalTab[];
|
|
40
|
+
|
|
41
|
+
contact: PortalContact;
|
|
42
|
+
lastUpdated: string;
|
|
43
|
+
hideFooterOnTab?: string;
|
|
44
|
+
|
|
45
|
+
shareEndpoint?: string;
|
|
46
|
+
eventsEndpoint?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ── Visitor ID (sp_visitor cookie, 30-day, first-party UUID) ─────────────────
|
|
50
|
+
|
|
51
|
+
function getOrCreateVisitorId(): string {
|
|
52
|
+
if (typeof document === "undefined") return "";
|
|
53
|
+
const match = document.cookie.match(/(?:^|; )sp_visitor=([^;]+)/);
|
|
54
|
+
if (match) return match[1];
|
|
55
|
+
const id = crypto.randomUUID();
|
|
56
|
+
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toUTCString();
|
|
57
|
+
document.cookie = `sp_visitor=${id}; path=/; expires=${expires}; SameSite=Lax`;
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Event tracking ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function trackEvent(
|
|
64
|
+
eventsEndpoint: string,
|
|
65
|
+
event: PortalEventType,
|
|
66
|
+
detail?: string,
|
|
67
|
+
visitorId?: string,
|
|
68
|
+
metadata?: PortalEventMetadata
|
|
69
|
+
) {
|
|
70
|
+
fetch(eventsEndpoint, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ event, detail, visitorId, metadata }),
|
|
74
|
+
}).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readHashTab(tabIds: string[]): string {
|
|
78
|
+
if (typeof window === "undefined") return tabIds[0] ?? "";
|
|
79
|
+
const hash = window.location.hash.replace("#", "");
|
|
80
|
+
return tabIds.includes(hash) ? hash : tabIds[0] ?? "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Section time tracking via Intersection Observer ──────────────────────────
|
|
84
|
+
|
|
85
|
+
function useSectionTimeTracking(
|
|
86
|
+
activeTab: string,
|
|
87
|
+
eventsEndpoint: string,
|
|
88
|
+
visitorId: string
|
|
89
|
+
) {
|
|
90
|
+
const sectionTimers = useRef<Map<string, number>>(new Map());
|
|
91
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
92
|
+
|
|
93
|
+
const flushSectionTime = useCallback(
|
|
94
|
+
(sectionId: string) => {
|
|
95
|
+
const startTime = sectionTimers.current.get(sectionId);
|
|
96
|
+
if (!startTime) return;
|
|
97
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
98
|
+
sectionTimers.current.delete(sectionId);
|
|
99
|
+
if (duration >= 2) {
|
|
100
|
+
trackEvent(eventsEndpoint, "section_time", sectionId, visitorId, {
|
|
101
|
+
[ANALYTICS_METADATA_KEYS.durationSeconds]: duration,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[eventsEndpoint, visitorId]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
110
|
+
|
|
111
|
+
// Flush all timers from previous tab
|
|
112
|
+
for (const sectionId of sectionTimers.current.keys()) {
|
|
113
|
+
flushSectionTime(sectionId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
observerRef.current?.disconnect();
|
|
117
|
+
|
|
118
|
+
const observer = new IntersectionObserver(
|
|
119
|
+
(entries) => {
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const sectionId = entry.target.getAttribute("data-section-id");
|
|
122
|
+
if (!sectionId) continue;
|
|
123
|
+
|
|
124
|
+
if (entry.isIntersecting) {
|
|
125
|
+
if (!sectionTimers.current.has(sectionId)) {
|
|
126
|
+
sectionTimers.current.set(sectionId, Date.now());
|
|
127
|
+
trackEvent(eventsEndpoint, "section_view", sectionId, visitorId);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
flushSectionTime(sectionId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{ threshold: 0.3 }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
observerRef.current = observer;
|
|
138
|
+
|
|
139
|
+
// Observe all elements with data-section-id within #main-content
|
|
140
|
+
const mainContent = document.getElementById("main-content");
|
|
141
|
+
if (mainContent) {
|
|
142
|
+
const sections = mainContent.querySelectorAll("[data-section-id]");
|
|
143
|
+
sections.forEach((el) => observer.observe(el));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return () => {
|
|
147
|
+
for (const sectionId of sectionTimers.current.keys()) {
|
|
148
|
+
flushSectionTime(sectionId);
|
|
149
|
+
}
|
|
150
|
+
observer.disconnect();
|
|
151
|
+
};
|
|
152
|
+
}, [activeTab, eventsEndpoint, visitorId, flushSectionTime]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── PortalShell component ────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export function PortalShell({
|
|
158
|
+
companyName,
|
|
159
|
+
companyLogo,
|
|
160
|
+
portalLabel,
|
|
161
|
+
clientName,
|
|
162
|
+
clientLogoSrc,
|
|
163
|
+
clientLogoAlt,
|
|
164
|
+
tabs,
|
|
165
|
+
contact,
|
|
166
|
+
lastUpdated,
|
|
167
|
+
hideFooterOnTab,
|
|
168
|
+
shareEndpoint,
|
|
169
|
+
eventsEndpoint,
|
|
170
|
+
}: PortalShellProps) {
|
|
171
|
+
const resolvedShareEndpoint = shareEndpoint ?? "/api/client-auth/share";
|
|
172
|
+
const resolvedEventsEndpoint = eventsEndpoint ?? "/api/client-events";
|
|
173
|
+
const resolvedPortalLabel = portalLabel ?? "Client Portal";
|
|
174
|
+
const resolvedContactMessage = contact.message ?? "Reach out anytime";
|
|
175
|
+
|
|
176
|
+
const tabIds = tabs.map((tab) => tab.id);
|
|
177
|
+
const defaultTab = tabIds[0] ?? "";
|
|
178
|
+
|
|
179
|
+
const [activeTab, setActiveTab] = useState(defaultTab);
|
|
180
|
+
const [copied, setCopied] = useState(false);
|
|
181
|
+
const [copyError, setCopyError] = useState(false);
|
|
182
|
+
const [visitorId] = useState(() => getOrCreateVisitorId());
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const syncFromHash = () => setActiveTab(readHashTab(tabIds));
|
|
186
|
+
syncFromHash();
|
|
187
|
+
window.addEventListener("hashchange", syncFromHash);
|
|
188
|
+
return () => window.removeEventListener("hashchange", syncFromHash);
|
|
189
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
trackEvent(resolvedEventsEndpoint, "portal_view", undefined, visitorId);
|
|
194
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!copied && !copyError) return;
|
|
199
|
+
const timeout = window.setTimeout(() => { setCopied(false); setCopyError(false); }, 2000);
|
|
200
|
+
return () => window.clearTimeout(timeout);
|
|
201
|
+
}, [copied, copyError]);
|
|
202
|
+
|
|
203
|
+
useSectionTimeTracking(activeTab, resolvedEventsEndpoint, visitorId);
|
|
204
|
+
|
|
205
|
+
function switchTab(tab: string) {
|
|
206
|
+
setActiveTab(tab);
|
|
207
|
+
window.history.replaceState(null, "", `#${tab}`);
|
|
208
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
209
|
+
trackEvent(resolvedEventsEndpoint, "tab_switch", tab, visitorId);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleShare() {
|
|
213
|
+
try {
|
|
214
|
+
const response = await fetch(resolvedShareEndpoint);
|
|
215
|
+
if (!response.ok) { setCopyError(true); return; }
|
|
216
|
+
|
|
217
|
+
const { shareUrl } = (await response.json()) as { shareUrl?: string };
|
|
218
|
+
if (!shareUrl) { setCopyError(true); return; }
|
|
219
|
+
|
|
220
|
+
await navigator.clipboard.writeText(
|
|
221
|
+
activeTab === defaultTab ? shareUrl : `${shareUrl}#${activeTab}`
|
|
222
|
+
);
|
|
223
|
+
setCopied(true);
|
|
224
|
+
} catch {
|
|
225
|
+
setCopyError(true);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const activeContent = tabs.find((t) => t.id === activeTab)?.content ?? null;
|
|
230
|
+
const showFooter = hideFooterOnTab ? activeTab !== hideFooterOnTab : true;
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className="flex min-h-screen flex-col bg-gray-50">
|
|
234
|
+
<div className="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur">
|
|
235
|
+
<header className="border-b bg-white/90">
|
|
236
|
+
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-3 sm:px-6">
|
|
237
|
+
<div className="flex items-center gap-3">
|
|
238
|
+
<div className="relative flex items-center">
|
|
239
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-gray-900">
|
|
240
|
+
{companyLogo}
|
|
241
|
+
</div>
|
|
242
|
+
<img
|
|
243
|
+
src={clientLogoSrc}
|
|
244
|
+
alt={clientLogoAlt}
|
|
245
|
+
width={32}
|
|
246
|
+
height={32}
|
|
247
|
+
className="-ml-2 h-8 w-8 rounded-full border-2 border-white"
|
|
248
|
+
loading="eager"
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
<div>
|
|
252
|
+
<h1 className="text-sm font-bold tracking-tight text-gray-900">
|
|
253
|
+
{clientName}
|
|
254
|
+
</h1>
|
|
255
|
+
<p className="text-[11px] text-gray-500">{companyName} {resolvedPortalLabel}</p>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={handleShare}
|
|
261
|
+
className={cn(
|
|
262
|
+
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",
|
|
263
|
+
copyError
|
|
264
|
+
? "border-red-200 text-red-600"
|
|
265
|
+
: "border-gray-200 text-gray-600 hover:bg-gray-50"
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
268
|
+
{copied ? (
|
|
269
|
+
<Check className="h-3.5 w-3.5 text-green-500" />
|
|
270
|
+
) : (
|
|
271
|
+
<Copy className="h-3.5 w-3.5" />
|
|
272
|
+
)}
|
|
273
|
+
{copied ? "Copied!" : copyError ? "Failed to copy" : "Copy secure link"}
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</header>
|
|
277
|
+
|
|
278
|
+
<div className="bg-white/90">
|
|
279
|
+
<div role="tablist" className="mx-auto flex max-w-4xl gap-1 overflow-x-auto px-4 sm:px-6">
|
|
280
|
+
{tabs.map((tab) => {
|
|
281
|
+
const Icon = tab.icon;
|
|
282
|
+
return (
|
|
283
|
+
<button
|
|
284
|
+
key={tab.id}
|
|
285
|
+
type="button"
|
|
286
|
+
role="tab"
|
|
287
|
+
aria-selected={activeTab === tab.id}
|
|
288
|
+
aria-controls={`tabpanel-${tab.id}`}
|
|
289
|
+
id={`tab-${tab.id}`}
|
|
290
|
+
onClick={() => switchTab(tab.id)}
|
|
291
|
+
className={cn(
|
|
292
|
+
"flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-sm font-medium transition-colors sm:gap-2 sm:px-4",
|
|
293
|
+
activeTab === tab.id
|
|
294
|
+
? "border-primary text-primary"
|
|
295
|
+
: "border-transparent text-gray-400 hover:text-gray-700"
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
<span className="relative">
|
|
299
|
+
<Icon className="h-4 w-4" />
|
|
300
|
+
{tab.badge && activeTab !== tab.id ? (
|
|
301
|
+
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-amber-400" />
|
|
302
|
+
) : null}
|
|
303
|
+
</span>
|
|
304
|
+
<span className="hidden sm:inline">{tab.label}</span>
|
|
305
|
+
<span className="sm:hidden">{tab.label.split(" ")[0]}</span>
|
|
306
|
+
</button>
|
|
307
|
+
);
|
|
308
|
+
})}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<main
|
|
314
|
+
id="main-content"
|
|
315
|
+
role="tabpanel"
|
|
316
|
+
aria-labelledby={`tab-${activeTab}`}
|
|
317
|
+
className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-6 sm:py-8"
|
|
318
|
+
>
|
|
319
|
+
{activeContent}
|
|
320
|
+
</main>
|
|
321
|
+
|
|
322
|
+
{showFooter ? (
|
|
323
|
+
<footer className="mt-auto border-t bg-white">
|
|
324
|
+
<div className="mx-auto flex max-w-4xl items-center gap-4 px-4 py-4 sm:px-6">
|
|
325
|
+
<img
|
|
326
|
+
src={contact.avatarSrc}
|
|
327
|
+
alt={contact.name}
|
|
328
|
+
width={32}
|
|
329
|
+
height={32}
|
|
330
|
+
className="h-8 w-8 shrink-0 rounded-full"
|
|
331
|
+
loading="lazy"
|
|
332
|
+
/>
|
|
333
|
+
<div className="flex-1">
|
|
334
|
+
<p className="text-xs">
|
|
335
|
+
<span className="font-semibold text-gray-900">{contact.name}</span>{" "}
|
|
336
|
+
<span className="text-gray-400">{contact.title}</span>
|
|
337
|
+
</p>
|
|
338
|
+
<p className="text-[11px] text-gray-500">{resolvedContactMessage}</p>
|
|
339
|
+
</div>
|
|
340
|
+
<div className="flex items-center gap-3">
|
|
341
|
+
<a
|
|
342
|
+
href={`mailto:${contact.email}`}
|
|
343
|
+
className="text-xs text-gray-400 transition-colors hover:text-gray-600"
|
|
344
|
+
>
|
|
345
|
+
{contact.email}
|
|
346
|
+
</a>
|
|
347
|
+
{contact.phone ? (
|
|
348
|
+
<a
|
|
349
|
+
href={`tel:${contact.phone}`}
|
|
350
|
+
className="text-xs text-gray-400 transition-colors hover:text-gray-600"
|
|
351
|
+
>
|
|
352
|
+
{contact.phoneDisplay ?? contact.phone}
|
|
353
|
+
</a>
|
|
354
|
+
) : null}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
<div className="flex items-center justify-center gap-2 pb-3">
|
|
358
|
+
<p className="text-[11px] text-gray-300">Last updated {lastUpdated}</p>
|
|
359
|
+
<span className="text-gray-200">·</span>
|
|
360
|
+
<a
|
|
361
|
+
href="https://showpane.com"
|
|
362
|
+
target="_blank"
|
|
363
|
+
rel="noopener noreferrer"
|
|
364
|
+
className="text-[10px] text-gray-300 transition-colors hover:text-gray-400"
|
|
365
|
+
>
|
|
366
|
+
Powered by Showpane
|
|
367
|
+
</a>
|
|
368
|
+
</div>
|
|
369
|
+
</footer>
|
|
370
|
+
) : null}
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
type RateLimitEntry = {
|
|
2
|
+
count: number;
|
|
3
|
+
resetAt: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
function checkRateLimit(
|
|
7
|
+
bucket: Map<string, RateLimitEntry>,
|
|
8
|
+
key: string,
|
|
9
|
+
limit: number,
|
|
10
|
+
windowMs: number
|
|
11
|
+
): boolean {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const existing = bucket.get(key);
|
|
14
|
+
|
|
15
|
+
if (!existing || now > existing.resetAt) {
|
|
16
|
+
if (bucket.size > 5000) {
|
|
17
|
+
for (const [bucketKey, entry] of bucket) {
|
|
18
|
+
if (now > entry.resetAt) bucket.delete(bucketKey);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
bucket.set(key, { count: 1, resetAt: now + windowMs });
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
existing.count += 1;
|
|
26
|
+
return existing.count > limit;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const uploadAttempts = new Map<string, RateLimitEntry>();
|
|
30
|
+
const eventAttempts = new Map<string, RateLimitEntry>();
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_PORTAL_STORAGE_QUOTA_BYTES = 500 * 1024 * 1024; // 500MB
|
|
33
|
+
export const UPLOAD_RATE_LIMIT_PER_MINUTE = 10;
|
|
34
|
+
export const EVENT_RATE_LIMIT_PER_MINUTE = 60;
|
|
35
|
+
export const EVENT_METADATA_MAX_BYTES = 4 * 1024;
|
|
36
|
+
|
|
37
|
+
export function isUploadRateLimited(key: string): boolean {
|
|
38
|
+
return checkRateLimit(uploadAttempts, key, UPLOAD_RATE_LIMIT_PER_MINUTE, 60_000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isEventRateLimited(key: string): boolean {
|
|
42
|
+
return checkRateLimit(eventAttempts, key, EVENT_RATE_LIMIT_PER_MINUTE, 60_000);
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-branding utility for Showpane.
|
|
3
|
+
* Fetches logos, avatars, and brand colors from free APIs.
|
|
4
|
+
* All functions return sensible defaults on failure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetch a company logo URL from a domain.
|
|
9
|
+
* Uses Clearbit Logo API (free, no key required).
|
|
10
|
+
* Falls back to a UI Avatars URL with the company initial.
|
|
11
|
+
*/
|
|
12
|
+
export function getLogoUrl(domain: string, fallbackName?: string): string {
|
|
13
|
+
if (domain) {
|
|
14
|
+
// Clearbit Logo API - free, no authentication needed
|
|
15
|
+
return `https://logo.clearbit.com/${domain}`;
|
|
16
|
+
}
|
|
17
|
+
// Fallback: initial-based avatar via ui-avatars.com
|
|
18
|
+
const initial = (fallbackName || "?")[0].toUpperCase();
|
|
19
|
+
return `https://ui-avatars.com/api/?name=${initial}&background=111827&color=fff&size=128&bold=true`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a Gravatar URL for an email address.
|
|
24
|
+
* Falls back to UI Avatars if no Gravatar exists.
|
|
25
|
+
*/
|
|
26
|
+
export function getAvatarUrl(email: string, fallbackName?: string): string {
|
|
27
|
+
if (email) {
|
|
28
|
+
// Use Gravatar with fallback to UI Avatars
|
|
29
|
+
const hash = email.trim().toLowerCase();
|
|
30
|
+
// Note: Real implementation would MD5 hash the email.
|
|
31
|
+
// For now, use UI Avatars as the Gravatar default (d=)
|
|
32
|
+
const fallback = encodeURIComponent(
|
|
33
|
+
`https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackName || email.split("@")[0])}&background=dbeafe&color=2563eb&size=128`
|
|
34
|
+
);
|
|
35
|
+
return `https://www.gravatar.com/avatar/?d=${fallback}`;
|
|
36
|
+
}
|
|
37
|
+
const name = fallbackName || "User";
|
|
38
|
+
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=dbeafe&color=2563eb&size=128`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate a placeholder logo as a data URI SVG.
|
|
43
|
+
* Used when no domain is available.
|
|
44
|
+
*/
|
|
45
|
+
export function getInitialLogo(name: string, bgColor = "#111827", textColor = "#ffffff"): string {
|
|
46
|
+
const initial = (name || "?")[0].toUpperCase();
|
|
47
|
+
return `data:image/svg+xml,${encodeURIComponent(
|
|
48
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="24" fill="${bgColor}"/><text x="64" y="64" dy=".35em" text-anchor="middle" fill="${textColor}" font-family="system-ui,sans-serif" font-size="64" font-weight="600">${initial}</text></svg>`
|
|
49
|
+
)}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client portal auth helpers.
|
|
3
|
+
*
|
|
4
|
+
* Wraps token.ts (pure crypto) with DB lookups and Next.js request/response helpers.
|
|
5
|
+
* For CLI scripts that need token operations without Next.js, import from token.ts directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getCredentialVersion } from "@/lib/client-portals";
|
|
9
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
10
|
+
import {
|
|
11
|
+
buildAndSignToken,
|
|
12
|
+
verifyTokenSignature,
|
|
13
|
+
isClientAuthConfigured,
|
|
14
|
+
SESSION_TOKEN_MAX_AGE_SECONDS,
|
|
15
|
+
SHARE_COOKIE_MAX_AGE_SECONDS,
|
|
16
|
+
SHARE_TOKEN_MAX_AGE_SECONDS,
|
|
17
|
+
} from "@/lib/token";
|
|
18
|
+
|
|
19
|
+
export { isClientAuthConfigured } from "@/lib/token";
|
|
20
|
+
export type { ClientTokenScope, VerifiedTokenPayload } from "@/lib/token";
|
|
21
|
+
|
|
22
|
+
export const CLIENT_AUTH_COOKIE = "client_auth";
|
|
23
|
+
export const CLIENT_AUTH_COOKIE_MAX_AGE_SECONDS = SESSION_TOKEN_MAX_AGE_SECONDS;
|
|
24
|
+
export const CLIENT_SHARE_TOKEN_MAX_AGE_SECONDS = SHARE_COOKIE_MAX_AGE_SECONDS;
|
|
25
|
+
|
|
26
|
+
export type VerifiedClientToken = {
|
|
27
|
+
orgId: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
scope: "session" | "share";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function signClientToken(
|
|
33
|
+
orgId: string,
|
|
34
|
+
slug: string,
|
|
35
|
+
scope: "session" | "share",
|
|
36
|
+
maxAgeSeconds: number | null
|
|
37
|
+
): Promise<string | null> {
|
|
38
|
+
const credentialVersion = await getCredentialVersion(orgId, slug);
|
|
39
|
+
if (!credentialVersion) return null;
|
|
40
|
+
return buildAndSignToken(orgId, slug, scope, maxAgeSeconds, credentialVersion);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function signSessionToken(orgId: string, slug: string): Promise<string | null> {
|
|
44
|
+
return signClientToken(orgId, slug, "session", SESSION_TOKEN_MAX_AGE_SECONDS);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function signShareToken(orgId: string, slug: string): Promise<string | null> {
|
|
48
|
+
return signClientToken(orgId, slug, "share", SHARE_TOKEN_MAX_AGE_SECONDS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function verifyClientToken(
|
|
52
|
+
token: string,
|
|
53
|
+
expectedScope?: "session" | "share"
|
|
54
|
+
): Promise<VerifiedClientToken | null> {
|
|
55
|
+
const payload = await verifyTokenSignature(token, expectedScope);
|
|
56
|
+
if (!payload) return null;
|
|
57
|
+
|
|
58
|
+
// Check credential version against DB
|
|
59
|
+
const credentialVersion = await getCredentialVersion(payload.orgId, payload.slug);
|
|
60
|
+
if (!credentialVersion || credentialVersion !== payload.ver) return null;
|
|
61
|
+
|
|
62
|
+
return { orgId: payload.orgId, slug: payload.slug, scope: payload.scope };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract authenticated identity from request cookie, or null. */
|
|
66
|
+
export async function getAuthenticatedPortal(
|
|
67
|
+
req: NextRequest,
|
|
68
|
+
expectedScope?: "session" | "share"
|
|
69
|
+
): Promise<{ orgId: string; slug: string; scope: "session" | "share" } | null> {
|
|
70
|
+
const token = req.cookies.get(CLIENT_AUTH_COOKIE)?.value;
|
|
71
|
+
if (!token) return null;
|
|
72
|
+
const verified = await verifyClientToken(token, expectedScope);
|
|
73
|
+
if (!verified) return null;
|
|
74
|
+
return { orgId: verified.orgId, slug: verified.slug, scope: verified.scope };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @deprecated Use getAuthenticatedPortal instead. Returns only slug for backward compat.
|
|
79
|
+
*/
|
|
80
|
+
export async function getAuthenticatedSlug(req: NextRequest): Promise<string | null> {
|
|
81
|
+
const portal = await getAuthenticatedPortal(req);
|
|
82
|
+
return portal?.slug ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Set the client auth cookie on a response. */
|
|
86
|
+
export function setClientAuthCookie(
|
|
87
|
+
res: NextResponse,
|
|
88
|
+
token: string,
|
|
89
|
+
maxAge = CLIENT_AUTH_COOKIE_MAX_AGE_SECONDS
|
|
90
|
+
) {
|
|
91
|
+
res.cookies.set(CLIENT_AUTH_COOKIE, token, {
|
|
92
|
+
httpOnly: true,
|
|
93
|
+
secure: process.env.NODE_ENV === "production",
|
|
94
|
+
sameSite: "lax",
|
|
95
|
+
path: "/",
|
|
96
|
+
maxAge,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { prisma } from "@/lib/db";
|
|
2
|
+
import {
|
|
3
|
+
getRuntimePortalBySlug,
|
|
4
|
+
getRuntimePortalByUsername,
|
|
5
|
+
getRuntimeState,
|
|
6
|
+
isRuntimeSnapshotMode,
|
|
7
|
+
} from "@/lib/runtime-state";
|
|
8
|
+
|
|
9
|
+
type ClientPortalRow = {
|
|
10
|
+
organizationId: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
companyName: string;
|
|
13
|
+
username: string;
|
|
14
|
+
passwordHash: string;
|
|
15
|
+
credentialVersion: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const PORTAL_SELECT = {
|
|
19
|
+
organizationId: true,
|
|
20
|
+
slug: true,
|
|
21
|
+
companyName: true,
|
|
22
|
+
username: true,
|
|
23
|
+
passwordHash: true,
|
|
24
|
+
credentialVersion: true,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export async function getClientPortalBySlug(
|
|
28
|
+
organizationId: string,
|
|
29
|
+
slug: string
|
|
30
|
+
): Promise<ClientPortalRow | null> {
|
|
31
|
+
if (isRuntimeSnapshotMode()) {
|
|
32
|
+
const portal = await getRuntimePortalBySlug(slug);
|
|
33
|
+
return portal
|
|
34
|
+
? {
|
|
35
|
+
organizationId,
|
|
36
|
+
slug: portal.slug,
|
|
37
|
+
companyName: portal.companyName,
|
|
38
|
+
username: portal.username,
|
|
39
|
+
passwordHash: portal.passwordHash,
|
|
40
|
+
credentialVersion: portal.credentialVersion,
|
|
41
|
+
}
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return prisma.clientPortal.findFirst({
|
|
46
|
+
where: { organizationId, slug, isActive: true },
|
|
47
|
+
select: PORTAL_SELECT,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function getClientPortalByUsername(
|
|
52
|
+
organizationId: string,
|
|
53
|
+
username: string
|
|
54
|
+
): Promise<ClientPortalRow | null> {
|
|
55
|
+
if (isRuntimeSnapshotMode()) {
|
|
56
|
+
const portal = await getRuntimePortalByUsername(username);
|
|
57
|
+
return portal
|
|
58
|
+
? {
|
|
59
|
+
organizationId,
|
|
60
|
+
slug: portal.slug,
|
|
61
|
+
companyName: portal.companyName,
|
|
62
|
+
username: portal.username,
|
|
63
|
+
passwordHash: portal.passwordHash,
|
|
64
|
+
credentialVersion: portal.credentialVersion,
|
|
65
|
+
}
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return prisma.clientPortal.findFirst({
|
|
70
|
+
where: { organizationId, username, isActive: true },
|
|
71
|
+
select: PORTAL_SELECT,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Look up the portal ID by org-scoped slug. */
|
|
76
|
+
export async function getClientPortalId(
|
|
77
|
+
organizationId: string,
|
|
78
|
+
slug: string
|
|
79
|
+
): Promise<string | null> {
|
|
80
|
+
const portal = await prisma.clientPortal.findFirst({
|
|
81
|
+
where: { organizationId, slug, isActive: true },
|
|
82
|
+
select: { id: true },
|
|
83
|
+
});
|
|
84
|
+
return portal?.id ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Validate login credentials. Returns the matching slug or null. */
|
|
88
|
+
export async function validateClientLogin(
|
|
89
|
+
organizationId: string,
|
|
90
|
+
username: string,
|
|
91
|
+
password: string
|
|
92
|
+
): Promise<string | null> {
|
|
93
|
+
const portal = await getClientPortalByUsername(organizationId, username);
|
|
94
|
+
if (!portal) return null;
|
|
95
|
+
const bcrypt = await import("bcryptjs");
|
|
96
|
+
const match = await bcrypt.compare(password, portal.passwordHash);
|
|
97
|
+
return match ? portal.slug : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Get the credential version string for token signing/verification. */
|
|
101
|
+
export async function getCredentialVersion(
|
|
102
|
+
organizationId: string,
|
|
103
|
+
slug: string
|
|
104
|
+
): Promise<string | null> {
|
|
105
|
+
const portal = await getClientPortalBySlug(organizationId, slug);
|
|
106
|
+
return portal?.credentialVersion ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the organizationId for the current request context.
|
|
111
|
+
* Cloud: each Vercel project has ORG_ID set during provisioning.
|
|
112
|
+
* Self-hosted: returns the single org in the DB.
|
|
113
|
+
*/
|
|
114
|
+
export async function resolveDefaultOrganizationId(): Promise<string | null> {
|
|
115
|
+
if (isRuntimeSnapshotMode()) {
|
|
116
|
+
const state = await getRuntimeState();
|
|
117
|
+
return state?.organization.id ?? process.env.ORG_ID ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cloud: each Vercel project has ORG_ID set during provisioning
|
|
121
|
+
if (process.env.ORG_ID) {
|
|
122
|
+
const org = await prisma.organization.findUnique({
|
|
123
|
+
where: { id: process.env.ORG_ID },
|
|
124
|
+
select: { id: true },
|
|
125
|
+
});
|
|
126
|
+
return org?.id ?? null;
|
|
127
|
+
}
|
|
128
|
+
// Self-hosted: use the single org in the DB
|
|
129
|
+
const org = await prisma.organization.findFirst({
|
|
130
|
+
select: { id: true },
|
|
131
|
+
orderBy: { createdAt: "asc" },
|
|
132
|
+
});
|
|
133
|
+
return org?.id ?? null;
|
|
134
|
+
}
|