Procurement: legal document pages + consent logging (P2-P4)

Builds on the versioned legal registry from the agreement PR.

Legal document viewing (P3/P4)
- GET /api/v1/legal/{docId} serves any registered doc (EULA, SLA exhibit,
  subprocessors) as markdown from the registry; a LegalDocumentModal renders
  it in-product with a draft badge. The SLA exhibit is one of these docs, so
  the Order Form's "per SLA Exhibit" reference is now viewable.

Consent logging (P3)
- New legal_consent table (V39 + Supabase twin) records clickwrap consents
  pinned to the exact document id + version. POST /api/v1/legal/consent.
- The trial-setup modal now requires accepting the EULA (with a link to read
  it) before starting, and the quote builder records EULA consent on generate.
  Best-effort — consent logging never blocks the flow.

Enhanced IP Protection (P2, light)
- Relabel the add-on to "Enhanced IP Protection" (patent coverage) and note
  that baseline IP indemnification is included free. The Order Form already
  renders it as elected/not from the agreement PR. Copy-only, no pricing change.

Out of scope (noted): self-serve PAYG / prepay consent surfaces don't exist
yet (separate self-serve billing workstream); a redundant quote-level legal-
entity column (signing already captures the authoritative legal name).

Verified: saas compiles; portal typecheck, eslint, prettier, dpdm (no cycles),
toml-sort, translation audits, and 176 portal/audit vitest tests pass.
This commit is contained in:
Connor Yoh
2026-07-13 15:33:46 +01:00
parent f6f484f184
commit c3dc6bc822
11 changed files with 473 additions and 61 deletions
@@ -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;
}
@@ -0,0 +1,5 @@
package stirling.software.saas.legal;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LegalConsentRepository extends JpaRepository<LegalConsent, Long> {}
@@ -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);
}
}
@@ -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<LegalDocumentResponse> document(@PathVariable String docId) {
return registry.meta(docId)
.<ResponseEntity<LegalDocumentResponse>>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<Void> consent(
@RequestBody ConsentRequest request, Authentication auth, HttpServletRequest http) {
if (request == null || request.documentId() == null || request.context() == null) {
return ResponseEntity.badRequest().build();
}
Optional<TeamMembership> 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<TeamMembership> 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();
}
}
@@ -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);
@@ -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"
@@ -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<AgreementDocument> {
return apiClient.saas.json<AgreementDocument>(`/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<void> {
return apiClient.saas
.json<void>("/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
@@ -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 (
<SideModal
open={docId !== null}
onClose={onClose}
wide
title={data?.displayName ?? t("portal.legal.title")}
subtitle={
data
? data.status !== "final"
? t("portal.legal.draft", { label: data.versionLabel })
: data.versionLabel
: undefined
}
>
{loading && (
<p className="portal-sidemodal__text">{t("portal.legal.loading")}</p>
)}
{!loading && !data && (
<p className="portal-sidemodal__text">{t("portal.legal.loadError")}</p>
)}
{data && (
<div className="portal-agreement__md">
<Markdown remarkPlugins={[remarkGfm]}>{data.markdown}</Markdown>
</div>
)}
</SideModal>
);
}
// ── Licence key ──────────────────────────────────────────────────────────────
export function LicenseModal({
open,
@@ -164,72 +216,102 @@ export function TrialSetupModal({
const { t } = useTranslation();
const [deployment, setDeployment] = useState<string>("cloud");
const [seats, setSeats] = useState("");
const [eula, setEula] = useState(false);
const [legalDoc, setLegalDoc] = useState<string | null>(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 (
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.setup.title")}
subtitle={t("portal.procurement.setup.subtitle")}
footer={
<Button
variant="primary"
accent="premium"
loading={busy}
onClick={() => onConfirm(deployment, Math.max(0, Number(seats) || 0))}
>
{t("portal.procurement.setup.start")}
</Button>
}
>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.deployment")}
</span>
<div className="portal-qb__opts">
{DEPLOYMENTS.map((d) => (
<button
key={d}
type="button"
className="portal-qb__opt"
data-on={deployment === d || undefined}
onClick={() => setDeployment(d)}
>
<span className="portal-qb__opt-title">
{t(`portal.procurement.setup.${d}`)}
</span>
<span className="portal-qb__opt-sub">
{t(`portal.procurement.setup.${d}Sub`)}
</span>
</button>
))}
</div>
</label>
const confirm = () => {
void recordLegalConsent("eula", "trial"); // clickwrap consent, best-effort
onConfirm(deployment, Math.max(0, Number(seats) || 0));
};
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.seats")}
</span>
<input
type="number"
min={0}
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
value={seats}
onChange={(e) => setSeats(e.target.value)}
/>
</label>
<p className="portal-sidemodal__text">
{t("portal.procurement.setup.seatsHint")}
</p>
</SideModal>
return (
<>
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.setup.title")}
subtitle={t("portal.procurement.setup.subtitle")}
footer={
<Button
variant="primary"
accent="premium"
loading={busy}
disabled={!eula}
onClick={confirm}
>
{t("portal.procurement.setup.start")}
</Button>
}
>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.deployment")}
</span>
<div className="portal-qb__opts">
{DEPLOYMENTS.map((d) => (
<button
key={d}
type="button"
className="portal-qb__opt"
data-on={deployment === d || undefined}
onClick={() => setDeployment(d)}
>
<span className="portal-qb__opt-title">
{t(`portal.procurement.setup.${d}`)}
</span>
<span className="portal-qb__opt-sub">
{t(`portal.procurement.setup.${d}Sub`)}
</span>
</button>
))}
</div>
</label>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.seats")}
</span>
<input
type="number"
min={0}
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
value={seats}
onChange={(e) => setSeats(e.target.value)}
/>
</label>
<p className="portal-sidemodal__text">
{t("portal.procurement.setup.seatsHint")}
</p>
<label className="portal-qb__eula">
<input
type="checkbox"
checked={eula}
onChange={(e) => setEula(e.target.checked)}
/>
<span>
{t("portal.procurement.setup.eula")}{" "}
<button
type="button"
className="portal-legal__link"
onClick={() => setLegalDoc("eula")}
>
{t("portal.procurement.setup.viewEula")}
</button>
</span>
</label>
</SideModal>
<LegalDocumentModal docId={legalDoc} onClose={() => setLegalDoc(null)} />
</>
);
}
@@ -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<string | null>(null);
const [busy, setBusy] = useState(false);
function set<K extends keyof QuoteConfigInput>(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)}
/>
<span>{t("portal.procurement.builder.eula")}</span>
<span>
{t("portal.procurement.builder.eula")}{" "}
<button
type="button"
className="portal-legal__link"
onClick={() => setLegalDoc("eula")}
>
{t("portal.procurement.builder.viewEula")}
</button>
</span>
</label>
</Step>
)}
@@ -437,6 +451,7 @@ export function QuoteBuilder({
)}
</div>
</div>
<LegalDocumentModal docId={legalDoc} onClose={() => setLegalDoc(null)} />
</div>
);
}
@@ -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<string, string> = {
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`, () => {
@@ -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;