diff --git a/server/routes/apps.js b/server/routes/apps.js index 3166a0a..a07bb66 100644 --- a/server/routes/apps.js +++ b/server/routes/apps.js @@ -468,14 +468,24 @@ router.get("/:appId/builds", async (req, res) => { try { const fields = "fields[builds]=version,processingState,uploadedDate,iconAssetToken,minOsVersion,buildAudienceType"; + const encryptionInclude = "include=appEncryptionDeclaration&fields[appEncryptionDeclarations]=usesNonExemptEncryption,appEncryptionDeclarationState"; let url; if (versionString) { - url = `/v1/builds?filter[app]=${appId}&filter[preReleaseVersion.version]=${encodeURIComponent(versionString)}&${fields}&limit=25`; + url = `/v1/builds?filter[app]=${appId}&filter[preReleaseVersion.version]=${encodeURIComponent(versionString)}&${fields}&${encryptionInclude}&limit=25`; } else { - url = `/v1/apps/${appId}/builds?${fields}&limit=25`; + url = `/v1/apps/${appId}/builds?${fields}&${encryptionInclude}&limit=25`; } const data = await ascFetch(account, url); + const includedDeclarations = new Map(); + if (data.included) { + for (const inc of data.included) { + if (inc.type === "appEncryptionDeclarations") { + includedDeclarations.set(inc.id, inc.attributes); + } + } + } + const builds = data.data.map((b) => { const attrs = b.attributes; let iconUrl = null; @@ -485,6 +495,8 @@ router.get("/:appId/builds", async (req, res) => { .replace("{h}", "128") .replace("{f}", "png"); } + const declId = b.relationships?.appEncryptionDeclaration?.data?.id || null; + const declAttrs = declId ? includedDeclarations.get(declId) : null; return { id: b.id, version: attrs.version, @@ -493,6 +505,9 @@ router.get("/:appId/builds", async (req, res) => { minOsVersion: attrs.minOsVersion, buildAudienceType: attrs.buildAudienceType, iconUrl, + encryptionDeclarationId: declId, + usesNonExemptEncryption: declAttrs?.usesNonExemptEncryption ?? null, + complianceState: declAttrs?.appEncryptionDeclarationState ?? null, }; }).sort((a, b) => new Date(b.uploadedDate) - new Date(a.uploadedDate)); @@ -578,6 +593,94 @@ router.patch("/:appId/versions/:versionId/build", async (req, res) => { } }); +// ── Build Encryption Compliance ───────────────────────────────────────────── + +router.get("/:appId/builds/:buildId/encryptionDeclaration", async (req, res) => { + const { buildId } = req.params; + const { accountId } = req.query; + + const cacheKey = `apps:build-encryption:${buildId}:${accountId || "default"}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId) || accounts[0]; + + try { + const data = await ascFetch( + account, + `/v1/builds/${buildId}/appEncryptionDeclaration?fields[appEncryptionDeclarations]=usesNonExemptEncryption,appEncryptionDeclarationState,containsProprietaryCryptography,containsThirdPartyCryptography,availableOnFrenchStore,codeValue,platform` + ); + + const decl = data.data + ? { + id: data.data.id, + ...data.data.attributes, + } + : null; + + const result = { declaration: decl }; + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error(`Failed to fetch encryption declaration for build ${buildId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.patch("/:appId/builds/:buildId/encryptionDeclaration", async (req, res) => { + const { appId, buildId } = req.params; + const { accountId, usesNonExemptEncryption, containsProprietaryCryptography, containsThirdPartyCryptography } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) { + return res.status(400).json({ error: "Account not found" }); + } + + try { + // Fetch the existing declaration ID + const declData = await ascFetch( + account, + `/v1/builds/${buildId}/appEncryptionDeclaration?fields[appEncryptionDeclarations]=appEncryptionDeclarationState` + ); + + if (!declData.data) { + return res.status(404).json({ error: "No encryption declaration found for this build" }); + } + + const declarationId = declData.data.id; + const attributes = { usesNonExemptEncryption }; + if (usesNonExemptEncryption) { + if (containsProprietaryCryptography !== undefined) attributes.containsProprietaryCryptography = containsProprietaryCryptography; + if (containsThirdPartyCryptography !== undefined) attributes.containsThirdPartyCryptography = containsThirdPartyCryptography; + } + + await ascFetch(account, `/v1/appEncryptionDeclarations/${declarationId}`, { + method: "PATCH", + body: { + data: { + type: "appEncryptionDeclarations", + id: declarationId, + attributes, + }, + }, + }); + + apiCache.deleteByPrefix(`apps:build-encryption:${buildId}:`); + apiCache.deleteByPrefix(`apps:builds:${appId}:`); + + res.json({ success: true }); + } catch (err) { + console.error(`Failed to update encryption declaration for build ${buildId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + // ── Version Localizations ─────────────────────────────────────────────────── function normalizeVersionLocalization(item) { diff --git a/src/api/index.js b/src/api/index.js index 1a7d68e..01b8865 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -98,6 +98,30 @@ export async function attachBuild(appId, versionId, buildId, accountId) { return res.json(); } +// ── Build Encryption Compliance ────────────────────────────────────────────── + +export async function fetchBuildEncryptionDeclaration(appId, buildId, accountId) { + const res = await fetch(`/api/apps/${appId}/builds/${buildId}/encryptionDeclaration?accountId=${accountId}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to fetch encryption declaration: ${res.status}`); + } + return res.json(); +} + +export async function updateBuildEncryptionDeclaration(appId, buildId, data) { + const res = await fetch(`/api/apps/${appId}/builds/${buildId}/encryptionDeclaration`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update encryption declaration: ${res.status}`); + } + return res.json(); +} + // ── Version Settings (release type, phased release, rating reset) ──────────── export async function updateVersionRelease(appId, versionId, { accountId, releaseType, earliestReleaseDate, resetRatingSummary }) { diff --git a/src/components/BuildComplianceModal.jsx b/src/components/BuildComplianceModal.jsx new file mode 100644 index 0000000..4aa818f --- /dev/null +++ b/src/components/BuildComplianceModal.jsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from "react"; +import { fetchBuildEncryptionDeclaration, updateBuildEncryptionDeclaration } from "../api/index.js"; + +const ALGORITHM_OPTIONS = [ + { + id: "proprietary", + label: "Encryption algorithms that are proprietary or not accepted as standard by international standard bodies (IEEE, IETF, ITU, etc.)", + proprietary: true, + thirdParty: false, + }, + { + id: "standard", + label: "Standard encryption algorithms instead of, or in addition to, using or accessing the encryption within Apple's operating system", + proprietary: false, + thirdParty: true, + }, + { + id: "both", + label: "Both algorithms mentioned above", + proprietary: true, + thirdParty: true, + }, + { + id: "none", + label: "None of the algorithms mentioned above", + proprietary: false, + thirdParty: false, + }, +]; + +export default function BuildComplianceModal({ build, appId, accountId, onClose, onSuccess, isMobile }) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [declaration, setDeclaration] = useState(null); + + // Step 1: encryption yes/no, Step 2: algorithm type + const [step, setStep] = useState(1); + const [usesEncryption, setUsesEncryption] = useState(null); + const [selectedAlgorithm, setSelectedAlgorithm] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const result = await fetchBuildEncryptionDeclaration(appId, build.id, accountId); + if (cancelled) return; + setDeclaration(result.declaration); + } catch (err) { + if (cancelled) return; + setError(err.message); + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { cancelled = true; }; + }, [appId, build.id, accountId]); + + async function handleSave() { + setSaving(true); + setError(null); + try { + const data = { accountId, usesNonExemptEncryption: usesEncryption }; + if (usesEncryption && selectedAlgorithm) { + const algo = ALGORITHM_OPTIONS.find((a) => a.id === selectedAlgorithm); + data.containsProprietaryCryptography = algo.proprietary; + data.containsThirdPartyCryptography = algo.thirdParty; + } + await updateBuildEncryptionDeclaration(appId, build.id, data); + onSuccess(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + function handleNoEncryption() { + setUsesEncryption(false); + setStep("saving"); + // Save immediately + setSaving(true); + setError(null); + updateBuildEncryptionDeclaration(appId, build.id, { + accountId, + usesNonExemptEncryption: false, + }) + .then(() => onSuccess()) + .catch((err) => { + setError(err.message); + setSaving(false); + setStep(1); + }); + } + + function handleYesEncryption() { + setUsesEncryption(true); + setStep(2); + } + + return ( +
+
e.stopPropagation()} + style={{ animation: "asc-fadein 0.3s ease" }} + className={`bg-dark-card border border-dark-border-light w-full overflow-y-auto shadow-[0_32px_64px_rgba(0,0,0,0.15)] ${ + isMobile + ? "rounded-t-2xl max-w-full max-h-[90vh]" + : "rounded-2xl max-w-[520px] max-h-[85vh]" + }`} + > + {/* Header */} +
+ Export Compliance + +
+ + {/* Content */} +
+ {loading ? ( +
+
{"\u21bb"}
+
Loading compliance info...
+
+ ) : error && step !== 2 ? ( +
+
Error
+
{error}
+
+ ) : step === 1 ? ( + <> + {/* Build info */} +
+
Build {build.version}
+ {declaration?.appEncryptionDeclarationState && ( +
+ + + {declaration.appEncryptionDeclarationState === "MISSING" ? "Missing Compliance" : declaration.appEncryptionDeclarationState} + +
+ )} +
+ +
+ Does your app use non-exempt encryption? +
+ +
+ + + +
+ + {/* Info box */} +
+
+ You can bypass this by adding ITSAppUsesNonExemptEncryption to your app's Info.plist. +
+
+ + ) : step === 2 ? ( + <> +
+ App Encryption Documentation +
+
+ What type of encryption algorithms does your app implement? +
+ +
+ {ALGORITHM_OPTIONS.map((opt) => ( + + ))} +
+ + {error && ( +
{error}
+ )} + + {/* Footer buttons */} +
+ + +
+ + ) : step === "saving" ? ( +
+
{"\u21bb"}
+
Saving compliance declaration...
+
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/BuildSelector.jsx b/src/components/BuildSelector.jsx index d38b7d3..70afd94 100644 --- a/src/components/BuildSelector.jsx +++ b/src/components/BuildSelector.jsx @@ -1,7 +1,13 @@ import { useState } from "react"; import BuildSelectorModal from "./BuildSelectorModal.jsx"; -export default function BuildSelector({ builds, attachedBuild, loading, attaching, attachingBuildId, error, onAttach, isMobile }) { +function isMissingCompliance(build) { + if (!build) return false; + const state = build.complianceState; + return state === "MISSING" || state === "INVALID" || (state && state.startsWith("MISSING")); +} + +export default function BuildSelector({ builds, attachedBuild, loading, attaching, attachingBuildId, error, onAttach, onManageCompliance, isMobile }) { const [showModal, setShowModal] = useState(false); function formatDate(dateString) { @@ -72,6 +78,20 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin Min OS {attachedBuild.minOsVersion} )} + {isMissingCompliance(attachedBuild) && ( +
+ + + + Missing Compliance + +
+ )}