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:
@@ -443,6 +443,37 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.badge.failed { background: rgba(255,59,48,0.15); color: var(--danger); }
|
||||
.badge.synced { background: rgba(48,209,88,0.15); color: var(--success); }
|
||||
.badge.unsynced { background: rgba(255,149,0,0.15); color: #ff9500; }
|
||||
.badge-valid { background: rgba(48,209,88,0.15); color: var(--success); display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
|
||||
.badge-expiring { background: rgba(255,149,0,0.15); color: #ff9500; display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
|
||||
.badge-expired { background: rgba(255,59,48,0.15); color: var(--danger); display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
|
||||
|
||||
/* Data tables (settings / profiles) */
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table th {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.action-cell { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.btn-xs { padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; border: 1px solid var(--border); }
|
||||
.btn-xs.btn-secondary { background: transparent; color: var(--text); }
|
||||
.btn-xs.btn-secondary:hover { background: var(--surface-hover); }
|
||||
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; }
|
||||
.btn-danger:hover { background: rgba(255,59,48,0.1); }
|
||||
.upload-label { display: inline-flex; align-items: center; cursor: pointer; }
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
@@ -495,6 +526,20 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--danger); }
|
||||
|
||||
/* Filesystem browser */
|
||||
.path-bar { display: flex; gap: 4px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||
.path-segment { color: var(--accent); cursor: pointer; font-size: 14px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
|
||||
.path-segment:hover { text-decoration: underline; }
|
||||
.path-separator { color: var(--text-muted); font-size: 12px; user-select: none; }
|
||||
|
||||
.file-list { display: flex; flex-direction: column; }
|
||||
.file-entry { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: background 0.1s; }
|
||||
.file-entry:hover { background: var(--surface-hover); }
|
||||
.file-entry.selected { background: rgba(0,122,255,0.1); border: 1px solid rgba(0,122,255,0.3); }
|
||||
.file-entry .icon { width: 24px; text-align: center; font-size: 16px; flex-shrink: 0; }
|
||||
.file-entry .name { font-size: 14px; }
|
||||
.file-entry.xcode-project .name { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* Log viewer */
|
||||
.log-viewer {
|
||||
background: #000;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
|
||||
|
||||
let selectedProject = null;
|
||||
|
||||
function toast(msg, kind = '') {
|
||||
const t = $('#toast');
|
||||
t.textContent = msg;
|
||||
@@ -6,46 +10,147 @@ function toast(msg, kind = '') {
|
||||
setTimeout(() => t.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
$('#upload-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Uploading…';
|
||||
// --- Path bar (breadcrumbs) ---
|
||||
|
||||
function renderPathBar(currentPath) {
|
||||
const bar = $('#path-bar');
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
let html = '';
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const fullPath = '/' + segments.slice(0, i + 1).join('/');
|
||||
if (i > 0) html += '<span class="path-separator">/</span>';
|
||||
html += `<span class="path-segment" data-path="${esc(fullPath)}">${esc(segments[i])}</span>`;
|
||||
}
|
||||
bar.innerHTML = html;
|
||||
bar.querySelectorAll('.path-segment').forEach((el) => {
|
||||
el.addEventListener('click', () => browse(el.dataset.path));
|
||||
});
|
||||
}
|
||||
|
||||
// --- File list ---
|
||||
|
||||
function renderFileList(entries) {
|
||||
const list = $('#file-list');
|
||||
|
||||
if (!entries.length) {
|
||||
list.innerHTML = '<p style="color:var(--text-muted);padding:8px 0">No Xcode projects or subdirectories found here.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = entries.map((e) => {
|
||||
let icon, cls;
|
||||
if (e.type === 'xcworkspace') {
|
||||
icon = '\u{1F4E6}';
|
||||
cls = 'xcode-project';
|
||||
} else if (e.type === 'xcodeproj') {
|
||||
icon = '\u{1F528}';
|
||||
cls = 'xcode-project';
|
||||
} else {
|
||||
icon = '\u{1F4C1}';
|
||||
cls = '';
|
||||
}
|
||||
return `<div class="file-entry ${cls}" data-path="${esc(e.path)}" data-type="${e.type}">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${esc(e.name)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.file-entry').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
if (el.dataset.type === 'directory') {
|
||||
browse(el.dataset.path);
|
||||
} else {
|
||||
selectProject(el.dataset.path);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Browse directory ---
|
||||
|
||||
async function browse(dirPath) {
|
||||
const list = $('#file-list');
|
||||
list.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';
|
||||
|
||||
const url = dirPath
|
||||
? `/api/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||
: '/api/filesystem/browse';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/build/upload', { method: 'POST', body: fd });
|
||||
const r = await fetch(url);
|
||||
if (r.status === 401) { location.href = '/login'; return; }
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Upload failed');
|
||||
location.href = `/builds#${data.job_id}`;
|
||||
if (!r.ok) throw new Error(data.error);
|
||||
|
||||
renderPathBar(data.path);
|
||||
renderFileList(data.entries);
|
||||
} catch (err) {
|
||||
list.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Select a project ---
|
||||
|
||||
async function selectProject(projectPath) {
|
||||
selectedProject = projectPath;
|
||||
const config = $('#project-config');
|
||||
config.style.display = 'block';
|
||||
$('#selected-project').textContent = projectPath;
|
||||
|
||||
const select = $('#scheme-select');
|
||||
select.disabled = true;
|
||||
select.innerHTML = '<option>Loading schemes...</option>';
|
||||
$('#build-btn').disabled = true;
|
||||
|
||||
document.querySelectorAll('.file-entry').forEach((el) => {
|
||||
el.classList.toggle('selected', el.dataset.path === projectPath);
|
||||
});
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/filesystem/schemes?projectPath=${encodeURIComponent(projectPath)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error);
|
||||
|
||||
if (!data.schemes.length) {
|
||||
select.innerHTML = '<option>No schemes found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = data.schemes.map((s) =>
|
||||
`<option value="${esc(s)}">${esc(s)}</option>`
|
||||
).join('');
|
||||
select.disabled = false;
|
||||
$('#build-btn').disabled = false;
|
||||
} catch (err) {
|
||||
select.innerHTML = `<option>Error: ${err.message}</option>`;
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build button ---
|
||||
|
||||
$('#build-btn').addEventListener('click', async () => {
|
||||
if (!selectedProject) return;
|
||||
const scheme = $('#scheme-select').value;
|
||||
const btn = $('#build-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Starting build...';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/build/local', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath: selectedProject, scheme }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Build failed to start');
|
||||
location.href = `/#${data.job_id}`;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Queue Build';
|
||||
btn.textContent = 'Build';
|
||||
}
|
||||
});
|
||||
|
||||
$('#git-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
url: e.target.url.value.trim(),
|
||||
branch: e.target.branch.value.trim() || null,
|
||||
scheme: e.target.scheme.value.trim() || null,
|
||||
};
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Cloning…';
|
||||
try {
|
||||
const r = await fetch('/api/build/git', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Clone failed');
|
||||
location.href = `/builds#${data.job_id}`;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Queue Build';
|
||||
}
|
||||
});
|
||||
// Start browsing at default path
|
||||
browse(null);
|
||||
|
||||
@@ -40,14 +40,14 @@ async function loadJobs() {
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Status</th><th>Bundle</th><th>Source</th><th>Started</th><th>Duration</th><th></th></tr>
|
||||
<tr><th>Status</th><th>Bundle</th><th>Project</th><th>Started</th><th>Duration</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${jobs.map(j => `
|
||||
<tr data-id="${j.id}" style="cursor:pointer">
|
||||
<td>${statusBadge(j.status)}</td>
|
||||
<td class="mono">${esc(j.bundle_id) || '<span style="color:var(--text-muted)">—</span>'}</td>
|
||||
<td class="mono">${esc(j.source_kind)}: ${esc((j.source_ref || '').slice(0, 40))}</td>
|
||||
<td class="mono">${esc((j.source_ref || '').replace(/\.(xcodeproj|xcworkspace)$/, ''))}</td>
|
||||
<td class="mono">${esc(formatDate(j.started_at))}</td>
|
||||
<td class="mono">${esc(duration(j.started_at, j.finished_at))}</td>
|
||||
<td>${j.install_url ? `<a href="${esc(j.install_url)}" class="btn-sm" style="background:var(--accent);color:white;padding:5px 12px;border-radius:14px;text-decoration:none;font-size:12px">Install</a>` : ''}</td>
|
||||
@@ -72,7 +72,7 @@ async function openJob(id) {
|
||||
$('#detail-meta').innerHTML = `
|
||||
<div>bundle: ${esc(job.bundle_id || '—')}</div>
|
||||
<div>scheme: ${esc(job.scheme || '—')}</div>
|
||||
<div>source: ${esc(job.source_kind)} ${esc(job.source_ref || '')}</div>
|
||||
<div>project: ${esc(job.source_ref || job.project_path || '--')}</div>
|
||||
<div>started: ${esc(formatDate(job.started_at))} · finished: ${esc(formatDate(job.finished_at))}</div>
|
||||
${job.install_url ? `<div>install: <a href="${esc(job.install_url)}" style="color:var(--accent)">${esc(job.install_url.slice(0, 80))}…</a></div>` : ''}
|
||||
${job.error ? `<div style="color:var(--danger)">error: ${esc(job.error)}</div>` : ''}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/api/devices');
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
const devices = await res.json();
|
||||
const container = $('#devices-container');
|
||||
|
||||
if (!devices.length) {
|
||||
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No devices registered yet.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>UDID</th><th>Status</th><th>Added</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${devices.map(d => `
|
||||
<tr>
|
||||
<td>${esc(d.name) || '<span class="mono" style="color:var(--text-muted)">unnamed</span>'}</td>
|
||||
<td class="mono">${esc(d.udid)}</td>
|
||||
<td>${d.synced_at
|
||||
? '<span class="badge synced">Synced</span>'
|
||||
: '<span class="badge unsynced">Local only</span>'}</td>
|
||||
<td class="mono">${esc(d.added_at)}</td>
|
||||
<td><button class="delete-btn" data-udid="${esc(d.udid)}" title="Delete">×</button></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Remove this device locally? (It will remain in the Apple portal.)')) return;
|
||||
const udid = btn.getAttribute('data-udid');
|
||||
const r = await fetch(`/api/devices/${udid}`, { method: 'DELETE' });
|
||||
if (r.ok) { toast('Removed', 'success'); load(); }
|
||||
else toast('Delete failed', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$('#add-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = {
|
||||
udid: form.udid.value.trim(),
|
||||
name: form.name.value.trim(),
|
||||
};
|
||||
const btn = form.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Registering…';
|
||||
|
||||
const r = await fetch('/api/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Add Device';
|
||||
|
||||
if (r.ok) {
|
||||
toast(data.synced ? 'Registered with Apple' : 'Saved locally (ASC not configured)', 'success');
|
||||
form.reset();
|
||||
load();
|
||||
} else {
|
||||
toast(data.error || 'Register failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
210
builder/public/js/profiles.js
Normal file
210
builder/public/js/profiles.js
Normal file
@@ -0,0 +1,210 @@
|
||||
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());
|
||||
@@ -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