diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java new file mode 100644 index 0000000000..5698133fe8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java @@ -0,0 +1,62 @@ +package stirling.software.saas.legal; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * An append-only record that a user accepted a versioned legal document at a particular moment in + * the product. Distinct from a signed agreement (which is a negotiated, signature-bearing artifact, + * see {@code ProcurementAgreementSignature}); this captures the lighter clickwrap consents — the + * EULA accepted at trial start and at quote generation — with the exact document version, so what + * was agreed is auditable even after the document versions up. + */ +@Entity +@Table(name = "legal_consent") +@NoArgsConstructor +@Getter +@Setter +public class LegalConsent implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "consent_id") + private Long consentId; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "document_version", nullable = false, length = 32) + private String documentVersion; + + // Where in the product the consent was given: "trial", "quote", etc. + @Column(name = "context", nullable = false, length = 32) + private String context; + + @Column(name = "signer_ip", length = 64) + private String signerIp; + + @CreationTimestamp + @Column(name = "consented_at", nullable = false, updatable = false) + private LocalDateTime consentedAt; +} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java new file mode 100644 index 0000000000..9d79640cd0 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java @@ -0,0 +1,5 @@ +package stirling.software.saas.legal; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LegalConsentRepository extends JpaRepository {} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java new file mode 100644 index 0000000000..c6377f35b7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java @@ -0,0 +1,47 @@ +package stirling.software.saas.legal; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** Records clickwrap consents to versioned legal documents (see {@link LegalConsent}). */ +@Slf4j +@Service +@Profile("saas") +@RequiredArgsConstructor +public class LegalConsentService { + + private final LegalDocumentRegistry registry; + private final LegalConsentRepository consents; + + /** + * Record that the given user accepted the current version of {@code documentId} in {@code + * context} (e.g. "trial", "quote"). No-op for an unknown document. Best-effort: callers treat a + * failure as non-fatal so it never blocks the flow the consent accompanies. + */ + @Transactional + public void record(Long teamId, Long userId, String documentId, String context, String ip) { + LegalDocumentMeta meta = registry.meta(documentId).orElse(null); + if (meta == null) { + log.warn("[legal] consent for unknown document '{}' ignored", documentId); + return; + } + LegalConsent consent = new LegalConsent(); + consent.setTeamId(teamId); + consent.setUserId(userId); + consent.setDocumentId(meta.id()); + consent.setDocumentVersion(meta.version()); + consent.setContext(context); + consent.setSignerIp(ip); + consents.save(consent); + log.info( + "[legal] consent recorded team={} doc={} v{} context={}", + teamId, + meta.id(), + meta.version(), + context); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalController.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalController.java new file mode 100644 index 0000000000..7ac9d29d65 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalController.java @@ -0,0 +1,113 @@ +package stirling.software.saas.legal; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Serves the versioned legal documents (EULA, SLA exhibit, subprocessors) for in-product viewing, + * and records the lighter clickwrap consents. The enterprise agreement itself is served + signed + * through the procurement controller, since it needs a quote to fill its Order Form. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/legal") +@Profile("saas") +@RequiredArgsConstructor +public class LegalController { + + private final LegalDocumentRegistry registry; + private final LegalConsentService consents; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + /** A legal document rendered for viewing: registry metadata + the static markdown body. */ + public record LegalDocumentResponse( + String docId, + String version, + String versionLabel, + String displayName, + String effectiveDate, + String status, + String markdown) {} + + public record ConsentRequest(String documentId, String context) {} + + /** Fetch a legal document's current version as markdown. 404 for an unknown document. */ + @GetMapping("/{docId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity document(@PathVariable String docId) { + return registry.meta(docId) + .>map( + meta -> + ResponseEntity.ok( + new LegalDocumentResponse( + meta.id(), + meta.version(), + meta.versionLabel(), + meta.displayName(), + meta.effectiveDate(), + meta.status(), + registry.staticMarkdown(docId)))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** + * Record a clickwrap consent (e.g. the EULA accepted at trial start or quote generation). + * Best-effort — a teamless caller still returns 200 so the accompanying flow is never blocked. + */ + @PostMapping("/consent") + @PreAuthorize("isAuthenticated()") + public ResponseEntity consent( + @RequestBody ConsentRequest request, Authentication auth, HttpServletRequest http) { + if (request == null || request.documentId() == null || request.context() == null) { + return ResponseEntity.badRequest().build(); + } + Optional membership = primaryMembership(auth); + Long teamId = membership.map(m -> m.getTeam().getId()).orElse(null); + Long userId = membership.map(m -> m.getUser().getId()).orElse(null); + consents.record(teamId, userId, request.documentId(), request.context(), clientIp(http)); + return ResponseEntity.ok().build(); + } + + private Optional primaryMembership(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return Optional.empty(); + } + return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst(); + } + + private static String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/app/saas/src/main/resources/db/migration/saas/V39__legal_consent.sql b/app/saas/src/main/resources/db/migration/saas/V39__legal_consent.sql new file mode 100644 index 0000000000..ac3da472c6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V39__legal_consent.sql @@ -0,0 +1,18 @@ +-- Clickwrap consents to versioned legal documents (the EULA accepted at trial start and at quote +-- generation). Append-only; each row pins the document id + version consented to, so what was +-- agreed stays auditable after the document versions up. Distinct from a signed agreement, which is +-- recorded in procurement_agreement_signature. Written/read by the Java backend via JPA. Additive +-- and idempotent (IF NOT EXISTS). + +CREATE TABLE IF NOT EXISTS stirling_pdf.legal_consent ( + consent_id BIGSERIAL PRIMARY KEY, + team_id BIGINT, + user_id BIGINT, + document_id VARCHAR(64) NOT NULL, + document_version VARCHAR(32) NOT NULL, + context VARCHAR(32) NOT NULL, + signer_ip VARCHAR(64), + consented_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_legal_consent_team ON stirling_pdf.legal_consent (team_id, consented_at DESC); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 134346144b..d3a54844e1 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7285,6 +7285,12 @@ models = "Models" security = "Security" storage = "Storage" +[portal.legal] +draft = "{{label}} · draft" +loadError = "Could not load this document. Please try again." +loading = "Loading…" +title = "Legal document" + [portal.nav] agent-builder = "Agent Builder" components = "Components" @@ -7731,8 +7737,8 @@ contactNamePlaceholder = "Jane Doe" continue = "Continue" eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote." generate = "Generate quote" -indemnification = "IP indemnification" -indemnificationSub = "We defend qualifying IP claims, per the EULA" +indemnification = "Enhanced IP Protection" +indemnificationSub = "Extends our IP defense to patent claims. Baseline copyright, trademark and trade-secret indemnification is included free." pdfSize = "PDF size" poNumber = "PO number" poNumberPlaceholder = "Optional" @@ -7783,6 +7789,7 @@ training = "Onboarding & training" trainingSub = "Live sessions to get your team running" users = "Total users" usersPlaceholder = "e.g. 250" +viewEula = "Read it" volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better." volManual = "Using your figure. Re-estimate from your team size any time." volNoUsers = "Not sure? Enter your team size and we'll estimate it." @@ -7938,6 +7945,7 @@ airgapSub = "Fully offline, isolated network. Includes a downloadable licence fi cloud = "Cloud" cloudSub = "Fully managed by Stirling. Nothing for you to run." deployment = "Where will you run Stirling?" +eula = "I agree to the Stirling EULA & Commercial Terms." seats = "Team size" seatsHint = "Roughly how many people will use it. You can refine this when you build your quote." seatsPlaceholder = "e.g. 250" @@ -7946,6 +7954,7 @@ selfhostSub = "Run it in your own cloud or data centre." start = "Start trial" subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required." title = "Set up your trial" +viewEula = "Read it" [portal.procurement.status] action = "Action needed" diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 3bcb619a5d..0739da96c5 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -463,6 +463,27 @@ export function recordAgreementSignature( ); } +/** Fetch a static legal document (eula, sla, subprocessors) by id for in-product viewing. */ +export function fetchLegalDocument(docId: string): Promise { + return apiClient.saas.json(`/api/v1/legal/${docId}`); +} + +/** + * Record a clickwrap consent to a legal document (e.g. the EULA at trial start / quote generation). + * Best-effort — never block the flow it accompanies on a consent-logging failure. + */ +export function recordLegalConsent( + documentId: string, + context: string, +): Promise { + return apiClient.saas + .json("/api/v1/legal/consent", { + method: "POST", + body: { documentId, context }, + }) + .catch(() => undefined); +} + // ---- Stripe Quote operations (Supabase edge functions) --------------------- // Java has no Stripe SDK, so issuing/accepting the quote and fetching its PDF run in edge functions // that own Stripe; they persist results back through SECURITY DEFINER RPCs. The portal invokes them diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 44bbea1264..ea4ad30f12 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -1,11 +1,18 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { Button } from "@app/ui"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { + fetchLegalDocument, + recordLegalConsent, + type ProcurementSnapshot, +} from "@portal/api/procurement"; import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; import { LicensePanel } from "@portal/components/procurement/ProcurementStages"; import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; +import { useAsync } from "@portal/hooks/useAsync"; import "@portal/views/Procurement.css"; /** @@ -74,6 +81,51 @@ function SideModal({ ); } +/** + * Reader for a versioned legal document (EULA, SLA exhibit, subprocessors), fetched from the + * backend registry and rendered as markdown. Open when {@code docId} is set. Drafts are badged. + */ +export function LegalDocumentModal({ + docId, + onClose, +}: { + docId: string | null; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { data, loading } = useAsync( + () => (docId ? fetchLegalDocument(docId) : Promise.resolve(null)), + [docId], + ); + return ( + + {loading && ( +

{t("portal.legal.loading")}

+ )} + {!loading && !data && ( +

{t("portal.legal.loadError")}

+ )} + {data && ( +
+ {data.markdown} +
+ )} +
+ ); +} + // ── Licence key ────────────────────────────────────────────────────────────── export function LicenseModal({ open, @@ -164,72 +216,102 @@ export function TrialSetupModal({ const { t } = useTranslation(); const [deployment, setDeployment] = useState("cloud"); const [seats, setSeats] = useState(""); + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); // Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger. useEffect(() => { if (open) { setDeployment("cloud"); setSeats(""); + setEula(false); } }, [open]); - return ( - onConfirm(deployment, Math.max(0, Number(seats) || 0))} - > - {t("portal.procurement.setup.start")} - - } - > - + const confirm = () => { + void recordLegalConsent("eula", "trial"); // clickwrap consent, best-effort + onConfirm(deployment, Math.max(0, Number(seats) || 0)); + }; - -

- {t("portal.procurement.setup.seatsHint")} -

-
+ return ( + <> + + {t("portal.procurement.setup.start")} + + } + > + + + +

+ {t("portal.procurement.setup.seatsHint")} +

+ + +
+ setLegalDoc(null)} /> + ); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index b392247563..26d8be7df9 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -9,9 +9,11 @@ import { import { money } from "@portal/components/procurement/format"; import { buildQuote, + recordLegalConsent, type QuoteConfigInput, type QuoteResult, } from "@portal/api/procurement"; +import { LegalDocumentModal } from "@portal/components/procurement/ProcurementExtras"; import "@portal/views/Procurement.css"; const STEPS = ["volume", "plan", "details"] as const; @@ -80,6 +82,7 @@ export function QuoteBuilder({ // A seeded quote carries a volume but no user count, so treat it as manually set. const [manualVolume, setManualVolume] = useState(initial != null); const [eula, setEula] = useState(initial != null); + const [legalDoc, setLegalDoc] = useState(null); const [busy, setBusy] = useState(false); function set(k: K, v: QuoteConfigInput[K]) { @@ -101,7 +104,9 @@ export function QuoteBuilder({ async function generate() { setBusy(true); try { - onGenerate(await buildQuote(cfg)); + const quote = await buildQuote(cfg); + void recordLegalConsent("eula", "quote"); // clickwrap consent, best-effort + onGenerate(quote); } finally { setBusy(false); } @@ -385,7 +390,16 @@ export function QuoteBuilder({ checked={eula} onChange={(e) => setEula(e.target.checked)} /> - {t("portal.procurement.builder.eula")} + + {t("portal.procurement.builder.eula")}{" "} + + )} @@ -437,6 +451,7 @@ export function QuoteBuilder({ )} + setLegalDoc(null)} /> ); } diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts index 60fe5f49cd..f614c23353 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts @@ -323,6 +323,34 @@ export const procurementSaasHandlers = [ resetProcurementSaasStore(); return HttpResponse.json(EMPTY); }), + http.get(`${SAAS}/api/v1/legal/:docId`, ({ params }) => { + const docId = String(params.docId); + const titles: Record = { + eula: "Stirling EULA & Commercial Terms", + sla: "Stirling SLA Exhibit", + subprocessors: "Stirling Subprocessors", + }; + if (!(docId in titles)) return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ + docId, + version: "1.0.0", + versionLabel: `${docId.toUpperCase()} v1.0.0`, + displayName: titles[docId], + effectiveDate: "2026-07-10", + status: "draft", + markdown: `# ${titles[docId]}\n\nThis is a mock of the ${docId} document for local development.\n\n## 1. Terms\n\nThe real text is served from the backend legal registry.`, + }); + }), + http.post(`${SAAS}/api/v1/legal/consent`, async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as Partial<{ + documentId: string; + context: string; + }>; + if (!body.documentId || !body.context) { + return new HttpResponse(null, { status: 400 }); + } + return new HttpResponse(null, { status: 200 }); + }), // Stripe Quote edge functions (supabase.functions.invoke → ${url}/functions/v1/{name}). http.post(`${SAAS}/functions/v1/issue-procurement-quote`, () => { diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index 38e54c7cd4..9c2fbdcf4e 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -1504,6 +1504,18 @@ font-size: 0.8125rem; margin: 0.5rem 0 0; } +.portal-legal__link { + border: none; + background: none; + padding: 0; + font: inherit; + color: var(--color-text-1); + text-decoration: underline; + cursor: pointer; +} +.portal-legal__link:hover { + color: var(--color-text-2, var(--color-text-1)); +} .portal-proc__reset { display: flex; justify-content: center;