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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user