Files
AppStore/builder/public/js/profiles.js
Trey T 491f3a22ba Builder v2: local project browser + multi-team ASC keys
Rewrites the builder console to browse local Xcode projects instead of
accepting source uploads or git URLs. Replaces the devices page with a
profiles page that manages ad-hoc provisioning profiles and lists
registered bundle IDs per team.

Adds multi-account support: ASC API keys are now stored in an asc_keys
table keyed by team_id (team_name, key_id, issuer_id, p8_filename). At
build time, the worker reads DEVELOPMENT_TEAM from the Xcode project and
auto-picks the matching key for fastlane sigh + JWT signing. Legacy
single-key settings auto-migrate on first boot.

Fixes storefront IPA parser to handle binary plists produced by Xcode.
Drops the enrollment bridge, device management routes, and direct
ASC API client -- fastlane sigh handles profile lifecycle now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 14:43:16 -05:00

211 lines
8.1 KiB
JavaScript

const $ = (s) => document.querySelector(s);
const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
function toast(msg, kind = '') {
const t = $('#toast');
t.textContent = msg;
t.className = 'toast show ' + kind;
setTimeout(() => t.classList.remove('show'), 3500);
}
function formatDate(s) {
if (!s) return '--';
return new Date(s).toLocaleDateString();
}
function statusBadge(status) {
const cls = status === 'valid' ? 'succeeded'
: status === 'expiring' ? 'pending'
: status === 'expired' ? 'failed'
: 'pending';
return `<span class="badge ${cls}">${status}</span>`;
}
function methodBadge(method) {
if (method === 'ad-hoc') return '<span class="badge running">ad-hoc</span>';
if (method === 'development') return '<span class="badge pending">dev</span>';
if (method === 'app-store') return '<span class="badge succeeded">app-store</span>';
if (method === 'enterprise') return '<span class="badge succeeded">enterprise</span>';
return `<span class="badge">${method || 'unknown'}</span>`;
}
// --- Installed profiles ---
let installedProfiles = [];
async function loadProfiles() {
const r = await fetch('/api/profiles');
if (r.status === 401) { location.href = '/login'; return; }
installedProfiles = await r.json();
const container = $('#profiles-container');
if (!installedProfiles.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No provisioning profiles installed. Generate one from the bundle IDs above.</p></div>';
return;
}
// Only show ad-hoc profiles
const adhoc = installedProfiles.filter(p => p.method === 'ad-hoc');
if (!adhoc.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No ad-hoc profiles found. Other profile types are installed but not shown.</p></div>';
return;
}
container.innerHTML = `
<table class="table">
<thead>
<tr>
<th>Bundle ID</th>
<th>Name</th>
<th>Devices</th>
<th>Expires</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${adhoc.map(p => `
<tr>
<td class="mono">${esc(p.bundleIdentifier || '--')}</td>
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.name)}">${esc(p.name || '--')}</td>
<td class="mono">${p.deviceCount ?? '--'}</td>
<td class="mono">${esc(formatDate(p.expiresAt))}</td>
<td>${statusBadge(p.status)}</td>
<td style="white-space:nowrap">
${p.bundleIdentifier ? `<button class="btn-sm btn-secondary regen-btn" data-bundle="${esc(p.bundleIdentifier)}" style="margin-right:4px">Regenerate</button>` : ''}
<button class="btn-sm btn-danger delete-btn" data-uuid="${esc(p.uuid)}">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
bindProfileActions(container);
}
function bindProfileActions(container) {
container.querySelectorAll('.delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this profile?')) return;
btn.disabled = true;
btn.textContent = '...';
try {
const r = await fetch(`/api/profiles/${btn.dataset.uuid}`, { method: 'DELETE' });
if (!r.ok) throw new Error((await r.json()).error || 'Delete failed');
toast('Profile deleted', 'success');
loadProfiles();
loadBundleIds();
} catch (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Delete';
}
});
});
container.querySelectorAll('.regen-btn').forEach((btn) => {
btn.addEventListener('click', () => generateProfile(btn.dataset.bundle, null, btn));
});
}
async function generateProfile(bundleId, teamId, btn) {
if (!teamId) {
// Regenerate button on the installed-profiles table doesn't know the team yet — look it up
const installed = installedProfiles.find(p => p.bundleIdentifier === bundleId);
teamId = installed?.teamId;
}
if (!teamId) {
toast('No team ID available for this bundle. Generate from the ASC bundle list above.', 'error');
return;
}
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Generating...';
try {
const r = await fetch('/api/profiles/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundleId, teamId }),
});
if (!r.ok) throw new Error((await r.json()).error || 'Generation failed');
toast(`Profile generated for ${bundleId}`, 'success');
loadProfiles();
loadBundleIds();
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = origText;
}
}
// --- Bundle IDs from ASC ---
async function loadBundleIds() {
const container = $('#bundle-ids-container');
try {
const r = await fetch('/api/bundle-ids');
if (r.status === 401) { location.href = '/login'; return; }
const groups = await r.json();
if (groups.error) throw new Error(groups.error);
if (!Array.isArray(groups) || !groups.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No developer accounts configured. Add one in Settings.</p></div>';
return;
}
const installedBundles = new Set(
installedProfiles
.filter(p => p.method === 'ad-hoc')
.map(p => p.bundleIdentifier)
);
container.innerHTML = groups.map(g => {
if (g.error) {
return `<div class="card" style="margin-bottom:16px">
<h3 style="margin-bottom:8px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px">${esc(g.teamId)}</span></h3>
<p style="color:var(--danger);font-size:13px">${esc(g.error)}</p>
</div>`;
}
if (!g.bundleIds.length) {
return `<div class="card" style="margin-bottom:16px">
<h3 style="margin-bottom:8px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px">${esc(g.teamId)}</span></h3>
<p style="color:var(--text-muted);font-size:13px">No bundle IDs registered.</p>
</div>`;
}
return `<div class="card" style="padding:0;overflow:hidden;margin-bottom:16px">
<div style="padding:12px 20px;border-bottom:1px solid var(--border);background:var(--bg)">
<h3 style="font-size:14px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px;margin-left:6px">${esc(g.teamId)}</span></h3>
</div>
${g.bundleIds.map(b => {
const hasProfile = installedBundles.has(b.identifier);
return `<div class="bundle-id-row" style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border)">
<div>
<span class="mono" style="font-size:14px">${esc(b.identifier)}</span>
<span style="color:var(--text-muted);font-size:12px;margin-left:8px">${esc(b.name)}</span>
<span style="color:var(--text-muted);font-size:11px;margin-left:6px">${esc(b.platform)}</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
${hasProfile
? '<span class="badge succeeded">has profile</span>'
: `<button class="btn-sm gen-btn" data-bundle="${esc(b.identifier)}" data-team="${esc(g.teamId)}">Generate Ad-Hoc</button>`
}
</div>
</div>`;
}).join('')}
</div>`;
}).join('');
container.querySelectorAll('.gen-btn').forEach((btn) => {
btn.addEventListener('click', () => generateProfile(btn.dataset.bundle, btn.dataset.team, btn));
});
} catch (err) {
container.innerHTML = `<div class="card"><p style="color:var(--danger)">${esc(err.message)}</p><p style="color:var(--text-muted);font-size:13px;margin-top:8px">Configure at least one Developer Account in Settings to fetch bundle IDs.</p></div>`;
}
}
// Load both in parallel
loadProfiles().then(() => loadBundleIds());