[grade=B] feat(auth): onboard missing credentials into encrypted vault
This commit is contained in:
@@ -32,8 +32,8 @@
|
||||
|
||||
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||
<div class="service-creds-content">
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||
@@ -91,7 +91,7 @@
|
||||
<!-- Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||
Save
|
||||
Save to encrypted vault
|
||||
</button>
|
||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||
Clear
|
||||
@@ -105,6 +105,8 @@
|
||||
|
||||
const modal = document.getElementById('service-creds-modal');
|
||||
let currentService = null;
|
||||
let credentialReturnUrl = null;
|
||||
let currentServiceHadCreds = false;
|
||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||
const qualityProfileServices = ['sonarr', 'radarr'];
|
||||
|
||||
@@ -124,8 +126,28 @@
|
||||
el.style.display = 'none';
|
||||
}
|
||||
|
||||
window.openServiceCredsModal = async function(service) {
|
||||
async function requireSuccessfulWrite(response, label) {
|
||||
if (!response) throw new Error(`${label} failed: no response`);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
throw new Error(`${label} failed: invalid server response`);
|
||||
}
|
||||
if (!response.ok || data?.success !== true) {
|
||||
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
|
||||
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
|
||||
}
|
||||
|
||||
window.openServiceCredsModal = async function(service, options = {}) {
|
||||
currentService = service;
|
||||
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
|
||||
currentServiceHadCreds = false;
|
||||
hideError();
|
||||
const title = document.getElementById('svc-creds-title');
|
||||
const desc = document.getElementById('svc-creds-desc');
|
||||
@@ -134,7 +156,10 @@
|
||||
const basicSection = document.getElementById('svc-creds-basic');
|
||||
const qualitySection = document.getElementById('svc-creds-quality');
|
||||
|
||||
title.textContent = service.name + ' Credentials';
|
||||
title.textContent = service.name + ' — Encrypted Vault';
|
||||
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
|
||||
? 'Save to vault & open service'
|
||||
: 'Save to encrypted vault';
|
||||
// Determine which sections to show
|
||||
const isExt = !!service.isExternal;
|
||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||
@@ -214,6 +239,7 @@
|
||||
}
|
||||
|
||||
if (hasCreds) {
|
||||
currentServiceHadCreds = true;
|
||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||
status.textContent = 'Credentials stored';
|
||||
@@ -352,16 +378,35 @@
|
||||
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
||||
const svcId = currentService.id || currentService.appTemplate;
|
||||
|
||||
if (credentialReturnUrl && !currentServiceHadCreds) {
|
||||
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const externalPass = document.getElementById('svc-seedhost-pass').value;
|
||||
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||
const requestedApiKey = apiKeyInput?.value.trim();
|
||||
const basicUser = document.getElementById('svc-basic-user').value.trim();
|
||||
const basicPass = document.getElementById('svc-basic-pass').value;
|
||||
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
|
||||
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
|
||||
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
|
||||
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
|
||||
showError('Enter the login or API key DashCaddy should store for this service.');
|
||||
saveBtn.textContent = 'Save to vault & open service';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save seedhost creds (shared username + per-service password)
|
||||
if (currentService.isExternal) {
|
||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||
if (user) {
|
||||
await secureFetch('/api/v1/seedhost-creds', {
|
||||
const response = await secureFetch('/api/v1/seedhost-creds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Seedhost credential save');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,23 +432,18 @@
|
||||
qualityProfileName: qualityProfileName || undefined
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
showError(data.error || 'Failed to save API key');
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const data = await requireSuccessfulWrite(res, 'ARR credential save');
|
||||
if (data.connectionTest && !data.connectionTest.success) {
|
||||
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
||||
}
|
||||
} else {
|
||||
// Non-arr services use the generic endpoint
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'API key save');
|
||||
}
|
||||
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
||||
// API key unchanged but user may have changed quality profile — save profile only
|
||||
@@ -411,11 +451,12 @@
|
||||
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
||||
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
||||
if (qualityProfileId) {
|
||||
await secureFetch('/api/v1/arr/quality-profiles', {
|
||||
const response = await secureFetch('/api/v1/arr/quality-profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Quality profile save');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,20 +465,28 @@
|
||||
const user = document.getElementById('svc-basic-user').value.trim();
|
||||
const pass = document.getElementById('svc-basic-pass').value;
|
||||
if (user && pass) {
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Service credential save');
|
||||
}
|
||||
}
|
||||
|
||||
await loadServiceCreds(currentService);
|
||||
if (credentialReturnUrl) {
|
||||
const returnUrl = credentialReturnUrl;
|
||||
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
|
||||
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
|
||||
credentialReturnUrl = null;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
||||
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
@@ -450,12 +499,15 @@
|
||||
const svcId = currentService.id || currentService.appTemplate;
|
||||
const isArr = arrServices.includes(svcId);
|
||||
if (currentService.isExternal) {
|
||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(response, 'Seedhost credential removal');
|
||||
}
|
||||
// Delete from both namespaces
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(response, 'Service credential removal');
|
||||
if (isArr) {
|
||||
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
|
||||
}
|
||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||
if (btn) btn.classList.remove('has-creds');
|
||||
@@ -470,11 +522,13 @@
|
||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
credentialReturnUrl = null;
|
||||
});
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
credentialReturnUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -501,4 +555,18 @@
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Protected service login pages send missing credentials here. Reuse the
|
||||
// normal vault form, then resume through the existing one-time SSO handoff.
|
||||
window.openRequestedCredentialForm = function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const serviceId = params.get('credentials');
|
||||
if (!serviceId) return false;
|
||||
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
|
||||
if (!service) return false;
|
||||
const returnUrl = params.get('return');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
window.openServiceCredsModal(service, { returnUrl });
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user