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.
Files changed (106) hide show
  1. package/README.md +26 -1
  2. package/bundle/meta/scaffold-manifest.json +73 -0
  3. package/bundle/scaffold/VERSION +1 -0
  4. package/bundle/scaffold/__dot__env.example +24 -0
  5. package/bundle/scaffold/__dot__gitignore +41 -0
  6. package/bundle/scaffold/docker/Caddyfile +3 -0
  7. package/bundle/scaffold/docker/Dockerfile +30 -0
  8. package/bundle/scaffold/docker-compose.yml +53 -0
  9. package/bundle/scaffold/next.config.ts +20 -0
  10. package/bundle/scaffold/package-lock.json +5843 -0
  11. package/bundle/scaffold/package.json +42 -0
  12. package/bundle/scaffold/postcss.config.js +6 -0
  13. package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
  14. package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
  15. package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
  16. package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
  17. package/bundle/scaffold/prisma/schema.local.prisma +131 -0
  18. package/bundle/scaffold/prisma/schema.prisma +128 -0
  19. package/bundle/scaffold/prisma/seed.ts +49 -0
  20. package/bundle/scaffold/public/example-avatar.svg +4 -0
  21. package/bundle/scaffold/public/example-logo.svg +4 -0
  22. package/bundle/scaffold/public/robots.txt +2 -0
  23. package/bundle/scaffold/scripts/backup.sh +19 -0
  24. package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
  25. package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
  26. package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
  27. package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
  28. package/bundle/scaffold/scripts/restore.sh +31 -0
  29. package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
  30. package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
  31. package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
  32. package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
  33. package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
  34. package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
  35. package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
  36. package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
  37. package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
  38. package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
  39. package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
  40. package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
  41. package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
  42. package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
  43. package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
  44. package/bundle/scaffold/src/app/api/health/route.ts +19 -0
  45. package/bundle/scaffold/src/app/globals.css +7 -0
  46. package/bundle/scaffold/src/app/layout.tsx +25 -0
  47. package/bundle/scaffold/src/app/page.tsx +177 -0
  48. package/bundle/scaffold/src/components/portal-login.tsx +169 -0
  49. package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
  50. package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
  51. package/bundle/scaffold/src/lib/branding.ts +50 -0
  52. package/bundle/scaffold/src/lib/client-auth.ts +98 -0
  53. package/bundle/scaffold/src/lib/client-portals.ts +134 -0
  54. package/bundle/scaffold/src/lib/control-plane.ts +100 -0
  55. package/bundle/scaffold/src/lib/db.ts +7 -0
  56. package/bundle/scaffold/src/lib/files.ts +124 -0
  57. package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
  58. package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
  59. package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
  60. package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
  61. package/bundle/scaffold/src/lib/storage.ts +204 -0
  62. package/bundle/scaffold/src/lib/token.ts +186 -0
  63. package/bundle/scaffold/src/lib/utils.ts +6 -0
  64. package/bundle/scaffold/src/middleware.ts +61 -0
  65. package/bundle/scaffold/tailwind.config.ts +15 -0
  66. package/bundle/scaffold/tests/__dot__gitkeep +0 -0
  67. package/bundle/scaffold/tsconfig.json +23 -0
  68. package/bundle/scaffold/vitest.config.ts +13 -0
  69. package/bundle/toolchain/VERSION +1 -0
  70. package/bundle/toolchain/bin/check-slug.ts +59 -0
  71. package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
  72. package/bundle/toolchain/bin/create-portal.ts +71 -0
  73. package/bundle/toolchain/bin/delete-portal.ts +48 -0
  74. package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
  75. package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
  76. package/bundle/toolchain/bin/generate-share-link.ts +68 -0
  77. package/bundle/toolchain/bin/list-portals.ts +53 -0
  78. package/bundle/toolchain/bin/materialize-file.ts +35 -0
  79. package/bundle/toolchain/bin/query-analytics.ts +88 -0
  80. package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
  81. package/bundle/toolchain/bin/showpane-config +63 -0
  82. package/bundle/toolchain/bin/tsconfig.json +13 -0
  83. package/bundle/toolchain/skills/VERSION +1 -0
  84. package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
  85. package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
  86. package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
  87. package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
  88. package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
  89. package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
  90. package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
  91. package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
  92. package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
  93. package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
  94. package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
  95. package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
  96. package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
  97. package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
  98. package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
  99. package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
  100. package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
  101. package/bundle/toolchain/skills/shared/preamble.md +137 -0
  102. package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
  103. package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
  104. package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
  105. package/dist/index.js +1248 -164
  106. 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
+ }