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>
This commit is contained in:
@@ -6,23 +6,131 @@ const toast = (msg, kind = '') => {
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
};
|
||||
|
||||
async function load() {
|
||||
const escapeHtml = (str) => (str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function loadStorefront() {
|
||||
const res = await fetch('/api/settings');
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
const s = await res.json();
|
||||
|
||||
$('[name=asc_key_id]').value = s.asc_key_id || '';
|
||||
$('[name=asc_issuer_id]').value = s.asc_issuer_id || '';
|
||||
$('[name=unraid_url]').value = s.unraid_url || '';
|
||||
$('[name=unraid_token]').value = s.unraid_token || '';
|
||||
|
||||
$('#p8-status').textContent = s.asc_key_uploaded
|
||||
? `✓ .p8 uploaded for key ${s.asc_key_id}`
|
||||
: 'No .p8 uploaded yet';
|
||||
}
|
||||
|
||||
async function saveForm(formEl, keys) {
|
||||
const data = Object.fromEntries(keys.map(k => [k, formEl.querySelector(`[name=${k}]`)?.value || '']));
|
||||
async function loadKeys() {
|
||||
const res = await fetch('/api/asc-keys');
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
const keys = await res.json();
|
||||
const container = $('#asc-keys-table');
|
||||
|
||||
if (!keys.length) {
|
||||
container.innerHTML = '<p style="color:var(--text-muted);margin:0">No developer accounts configured yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team Name</th>
|
||||
<th>Team ID</th>
|
||||
<th>Key ID</th>
|
||||
<th>Issuer ID</th>
|
||||
<th>.p8</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${keys.map((k) => `
|
||||
<tr data-team-id="${escapeHtml(k.team_id)}">
|
||||
<td>${escapeHtml(k.team_name || '')}</td>
|
||||
<td><code>${escapeHtml(k.team_id)}</code></td>
|
||||
<td><code>${escapeHtml(k.key_id)}</code></td>
|
||||
<td><code style="font-size:11px">${escapeHtml(k.issuer_id)}</code></td>
|
||||
<td>${k.p8_uploaded ? '<span class="badge badge-valid">uploaded</span>' : '<span class="badge badge-expired">missing</span>'}</td>
|
||||
<td class="action-cell">
|
||||
<label class="btn-secondary btn-xs upload-label">
|
||||
<input type="file" accept=".p8" style="display:none" class="p8-input">
|
||||
Upload .p8
|
||||
</label>
|
||||
<button class="btn-secondary btn-xs test-btn">Test</button>
|
||||
<button class="btn-danger btn-xs delete-btn">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Wire row actions
|
||||
container.querySelectorAll('tr[data-team-id]').forEach((row) => {
|
||||
const teamId = row.dataset.teamId;
|
||||
row.querySelector('.p8-input').addEventListener('change', (e) => uploadP8(teamId, e.target.files[0]));
|
||||
row.querySelector('.test-btn').addEventListener('click', () => testKey(teamId));
|
||||
row.querySelector('.delete-btn').addEventListener('click', () => deleteKey(teamId));
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadP8(teamId, file) {
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('p8', file);
|
||||
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}/p8`, { method: 'POST', body: fd });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
toast('.p8 uploaded', 'success');
|
||||
loadKeys();
|
||||
} else {
|
||||
toast(data.error || 'Upload failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testKey(teamId) {
|
||||
toast('Testing...', '');
|
||||
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}/test`, { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) toast(`Team ${teamId} authenticated OK`, 'success');
|
||||
else toast(data.error || 'Test failed', 'error');
|
||||
}
|
||||
|
||||
async function deleteKey(teamId) {
|
||||
if (!confirm(`Delete ASC key for team ${teamId}? The .p8 file will be removed from disk.`)) return;
|
||||
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}`, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
toast('Deleted', 'success');
|
||||
loadKeys();
|
||||
} else {
|
||||
toast(data.error || 'Delete failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$('#add-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const payload = Object.fromEntries(fd.entries());
|
||||
const res = await fetch('/api/asc-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
toast('Saved', 'success');
|
||||
e.target.reset();
|
||||
loadKeys();
|
||||
} else {
|
||||
toast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('#storefront-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
unraid_url: e.target.querySelector('[name=unraid_url]').value,
|
||||
unraid_token: e.target.querySelector('[name=unraid_token]').value,
|
||||
};
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -30,45 +138,14 @@ async function saveForm(formEl, keys) {
|
||||
});
|
||||
if (res.ok) toast('Saved', 'success');
|
||||
else toast('Save failed', 'error');
|
||||
}
|
||||
|
||||
$('#asc-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveForm(e.target, ['asc_key_id', 'asc_issuer_id']);
|
||||
|
||||
// Upload .p8 if one was selected
|
||||
const file = $('#p8-input').files[0];
|
||||
if (file) {
|
||||
const fd = new FormData();
|
||||
fd.append('p8', file);
|
||||
const res = await fetch('/api/settings/p8', { method: 'POST', body: fd });
|
||||
if (res.ok) {
|
||||
toast('.p8 uploaded', 'success');
|
||||
load();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast(err.error || 'Upload failed', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#unraid-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveForm(e.target, ['unraid_url', 'unraid_token']);
|
||||
});
|
||||
|
||||
$('#test-asc').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/settings/test-asc', { method: 'POST' });
|
||||
$('#test-storefront').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/settings/test-storefront', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) toast(`Connected — ${data.device_count} devices in portal`, 'success');
|
||||
if (res.ok) toast(`Connected -- ${data.app_count} apps on storefront`, 'success');
|
||||
else toast(data.error || 'Connection failed', 'error');
|
||||
});
|
||||
|
||||
$('#test-unraid').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/settings/test-unraid', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) toast(`Connected to unraid — ${data.app_count} apps`, 'success');
|
||||
else toast(data.error || 'Connection failed', 'error');
|
||||
});
|
||||
|
||||
load();
|
||||
loadStorefront();
|
||||
loadKeys();
|
||||
|
||||
Reference in New Issue
Block a user