diff --git a/apps/web/app.js b/apps/web/app.js index 4c6fe43..1c37487 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -161,6 +161,21 @@ async function createOutreachDraft(){if(!selectedId)return;const button=$('createOutreachDraftBtn');if(!button)return;const prospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(selectedId))||{},recipient=prospect.email||(prospect.contacts||[]).find(c=>c.email)?.email;if(!recipient){renderOutreachState('blocked','No recipient email is available for review.');return;}button.disabled=true;$('outreachDraftState').innerHTML='
Creating evidence-grounded draft…
';try{const evidence=prospect.evidence||prospect.evidence_timeline||[],body=evidence.length?'Hello, we noticed this about your business: {{evidence.1.claim}}':'Hello, this evidence-grounded draft is ready for your review.';await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/outreach/drafts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:{kind:'email',value:recipient,verified:true},subject:'Hello {{business.name}}',body,legal_basis:'pending human review',consent_confirmed:false,autonomous_action:false,send:false})});await loadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState(error.message.toLowerCase().includes('config')?'not-configured':error.message.toLowerCase().includes('supp')?'suppressed':'error',error.message);}finally{button.disabled=false;}} async function approveOutreachDraft(id){if(!id||!window.confirm('Approve this draft for recordkeeping? Approval does not send a message.'))return;try{await jsonRequest(`/api/v1/outreach/drafts/${encodeURIComponent(id)}/approve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,send:false,autonomous_action:false})});await loadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState('error',error.message);}} function renderProviderPolicy(payload){const panel=$('providerPolicyPanel');if(!panel)return;const items=payloadItems(payload,['providers','items','policies']),status=outreachState(payload?.status||payload?.state||'');if(['error','auth-required','suppressed','blocked','unknown'].includes(status)){panel.innerHTML=`
${esc(outreachLabel(status))} provider policy

${esc(payload?.message||payload?.reason||'No provider status was established. No secrets are displayed.')}

`;return;}if(!payload){panel.innerHTML='
Provider policy unavailable

No provider configuration was returned. No secrets are displayed.

';return;}panel.innerHTML=items.length?items.map(provider=>{const configured=Boolean(provider.configured??provider.is_configured),enabled=Boolean(provider.enabled??provider.active),providerStatus=provider.status||(!configured?'not-configured':enabled?'enabled':'disabled');return `
${esc(provider.name||provider.provider_id||provider.id||'Approved provider')}${esc(provider.model||provider.version||'Version not returned')}
${esc(outreachLabel(providerStatus))}
Configured
${configured?'Yes':'No'}
Enabled
${enabled?'Yes':'No'}
Policy
${esc(provider.policy_version||'Unknown')}
Secrets
Not displayed
`;}).join(''):`
Outreach provider not configured

Drafting and sending are unavailable until an approved provider is configured. No secrets are displayed.

`;} + const canEditAiProviders = () => ['admin','owner'].includes(String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase()) || Boolean(currentUser?.permissions?.includes?.('ai_provider:write')); + const providerStatusValue = value => String(value || 'not-configured').toLowerCase().replaceAll('_','-'); + const providerStatusLabel = value => ({configured:'Configured',ready:'Configured',enabled:'Configured',disabled:'Not configured','not-configured':'Not configured',invalid:'Invalid configuration',error:'Error',testing:'Testing…',connected:'Connected',unavailable:'Unavailable'})[providerStatusValue(value)] || 'Unknown'; + function renderAiProviderStatus(payload) { + const state=$('aiProviderStatus'); if(!state)return; + const services=payloadItems(payload,['providers','services','items']); + if (payload?.status==='error' && !services.length) { state.innerHTML=``; return; } + const rows=services.length?services:[{name:'Nous Portal',provider:'nous_portal',status:payload?.nous_portal?.status||payload?.status||'not-configured',model:payload?.model||payload?.nous_portal?.model,base_url:payload?.base_url||payload?.nous_portal?.base_url},{name:'Firecrawl',provider:'firecrawl',status:payload?.firecrawl?.status||payload?.firecrawl_status||'not-configured',base_url:payload?.firecrawl?.base_url}]; + state.innerHTML=`
${rows.map(service=>{const status=providerStatusValue(service.status||service.state||(service.configured?'configured':'not-configured')),configured=['configured','ready','enabled','connected'].includes(status);return `
${esc(service.name||service.provider||'Approved provider')}${esc(service.model||service.base_url||'Configuration details unavailable')}
${esc(providerStatusLabel(status))}

${configured?'Credentials configured; values are never displayed.':status==='invalid'?'Configuration was rejected. Review the fields and try again.':status==='not-configured'||status==='disabled'?'No credentials are configured for this service.':'No safe connection result was returned.'}

`;}).join('')}

Secret values, key fingerprints, and response bodies are never shown.

`; + } + function setAiProviderAdminState() { const state=$('aiProviderAdminState'),form=$('aiProviderForm'),test=$('testAiProviderBtn'); if(!state)return; const editable=canEditAiProviders(); state.hidden=editable; if(!editable){state.innerHTML='Administrator access required

Your role can view safe provider status, but only an administrator can edit or test credentials.

';form?.setAttribute('hidden','');if(test)test.disabled=true;}else{form?.removeAttribute('hidden');if(test)test.disabled=false;} } + function populateAiProviderFields(payload) { const source=payload?.nous_portal||payload?.provider_config||payload||{}; [['nousModel',source.model||source.nous_model],['nousBaseUrl',source.base_url||source.nous_base_url],['firecrawlBaseUrl',source.firecrawl_base_url||payload?.firecrawl?.base_url]].forEach(([id,value])=>{if(value!==undefined&&$(id))$(id).value=value;}); ['nousApiKey','firecrawlApiKey'].forEach(id=>{if($(id))$(id).value='';}); } + async function loadAiProviderSettings(){const state=$('aiProviderStatus');if(!state)return;setAiProviderAdminState();state.innerHTML='
Loading AI provider status…
';try{const payload=await jsonRequest('/api/v1/ai/provider-config');populateAiProviderFields(payload);renderAiProviderStatus(payload);}catch(error){if(error.message!=='unauthorized')renderAiProviderStatus({status:error.message.toLowerCase().includes('invalid')?'invalid':'error',message:error.message});}} + async function saveAiProviderSettings(event){event.preventDefault();if(!canEditAiProviders()){message('aiProviderMessage','Administrator access is required to update provider settings.',true);return;}const form=event.currentTarget,data=Object.fromEntries(new FormData(form).entries()),body={provider:data.provider,model:data.nous_model.trim(),nous_base_url:data.nous_base_url.trim(),firecrawl_base_url:data.firecrawl_base_url.trim(),enabled:true,credentials:{}};if(data.nous_api_key)body.credentials.nous_api_key=data.nous_api_key;if(data.firecrawl_api_key)body.credentials.firecrawl_api_key=data.firecrawl_api_key;const button=$('saveAiProviderBtn');button.disabled=true;message('aiProviderMessage','Saving provider settings…');try{await jsonRequest('/api/v1/ai/provider-config',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});form.querySelectorAll('input[type="password"]').forEach(input=>input.value='');message('aiProviderMessage','Provider settings saved. Discovery and AI work were not started.');await loadAiProviderSettings();}catch(error){if(error.message!=='unauthorized')message('aiProviderMessage',error.message||'Unable to save provider settings.',true);}finally{button.disabled=false;}} + async function testAiProviderConnection(){if(!canEditAiProviders())return;const button=$('testAiProviderBtn');button.disabled=true;const state=$('aiProviderStatus');state.innerHTML='
Testing provider connection…
';try{const payload=await jsonRequest('/api/v1/admin/ai-provider-config/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});renderAiProviderStatus(payload);message('aiProviderMessage',payload?.status==='invalid'?'Connection test returned an invalid configuration.':'Connection test completed; no secrets were returned.');}catch(error){if(error.message!=='unauthorized')renderAiProviderStatus({status:error.message.toLowerCase().includes('invalid')?'invalid':'error',message:error.message});}finally{button.disabled=false;}} async function loadProviderPolicy(){const panel=$('providerPolicyPanel');if(!panel)return;panel.innerHTML='
Loading provider policy…
';try{const payload=await jsonRequest('/api/v1/outreach/provider-config');renderProviderPolicy(payload);}catch(error){if(error.message!=='unauthorized')renderProviderPolicy({status:error.message.toLowerCase().includes('auth')?'auth-required':'error',message:error.message,providers:[]});}} function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence
Loading score breakdown…

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

AI ASSISTANCE

Evidence-grounded suggestion

AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.

Loading AI assistance…

OUTREACH PREPARATION

Evidence-grounded outreach draft

Draft-only preparation. Review the recipient and citations before approval. No message will be sent from this screen.

Loading outreach drafts…

Sending is disabled by policy; approval never sends a message.

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);loadOutreachDrafts(p.id);renderDedupPanel();} let mergeSource = null, mergeTarget = null, mergeBusy = false; @@ -279,11 +294,11 @@ function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='

No data rows found

';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`;} async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}} async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}} - async function bootstrap(){let res;try{res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});}catch(e){showLogin('Unable to connect to the workspace. Try again.');return;}if(res.status===401){showLogin();return;}if(!res.ok){showLogin('Unable to verify the workspace session. Try again.');return;}let user;try{user=await res.json();}catch(e){showLogin('Unable to read the workspace session. Try again.');return;}showDashboard(user.user||user);try{await loadData();await loadJobs();await loadSources();await loadDiscoveryRuns();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();await loadProviderPolicy();}catch(e){console.error('Workspace dashboard initialization failed',e);$('apiStatus').textContent='● Workspace partially loaded';$('apiStatus').classList.remove('live');setExplorerState('Some workspace panels could not be loaded. Use Refresh to retry.',true);}} + async function bootstrap(){let res;try{res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});}catch(e){showLogin('Unable to connect to the workspace. Try again.');return;}if(res.status===401){showLogin();return;}if(!res.ok){showLogin('Unable to verify the workspace session. Try again.');return;}let user;try{user=await res.json();}catch(e){showLogin('Unable to read the workspace session. Try again.');return;}showDashboard(user.user||user);try{await loadData();await loadJobs();await loadSources();await loadDiscoveryRuns();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();await loadProviderPolicy();await loadAiProviderSettings();}catch(e){console.error('Workspace dashboard initialization failed',e);$('apiStatus').textContent='● Workspace partially loaded';$('apiStatus').classList.remove('live');setExplorerState('Some workspace panels could not be loaded. Use Refresh to retry.',true);}} document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);}); document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();if(e.target.id==='createOutreachDraftBtn')createOutreachDraft();const outreachButton=e.target.closest?.('[data-outreach-action]');if(outreachButton)approveOutreachDraft(outreachButton.dataset.outreachId);const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); $('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('directDiscoveryForm').addEventListener('submit',submitDirectDiscovery);$('directDiscoveryRefreshBtn').addEventListener('click',loadDiscoveryRuns);$('directDiscoveryRunsState').addEventListener('click',e=>{const row=e.target.closest?.('[data-discovery-run-id]');if(row){const run=discoveryRuns.find(item=>String(item.id)===String(row.dataset.discoveryRunId));if(run)loadDiscoveryRun(run);}if(e.target.id==='retryDiscoveryRunsBtn')loadDiscoveryRuns();});$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); $('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});})); - $('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('outreachPolicyRefreshBtn').addEventListener('click',loadProviderPolicy); + $('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('aiProviderForm').addEventListener('submit',saveAiProviderSettings);$('aiProviderRefreshBtn').addEventListener('click',loadAiProviderSettings);$('testAiProviderBtn').addEventListener('click',testAiProviderConnection);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('outreachPolicyRefreshBtn').addEventListener('click',loadProviderPolicy); bootstrap(); })(); diff --git a/apps/web/asset-manifest.json b/apps/web/asset-manifest.json index 6b927f3..afd15ff 100644 --- a/apps/web/asset-manifest.json +++ b/apps/web/asset-manifest.json @@ -1,6 +1,6 @@ { "schema": 1, - "version": "phase-17", + "version": "phase-18", "entrypoints": [ "config.js", "app.js", @@ -14,9 +14,9 @@ ], "integrity": { "config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6", - "app.js": "sha256-cf0d8b00773877c09f03707030e3e8113b3f34ad389054e0a97ecb1dbddd139f", - "styles.css": "sha256-56ca598c8e8cbbcb91d81013c6a498f75193de79e33dd2d8d117df836fc43d57", - "index.html": "sha256-f3c9544c07c08c5248abafaf8075551ee641704f9cb3d516449188f74bf86644", + "app.js": "sha256-34521da899dacc84d58200258735f09515d4b527028e1ba8c53ce6b66ad77c8a", + "styles.css": "sha256-ca618b1a44a1f310029ab9d6986a4120e9147cd61784c3e7a0ff826cd2d42696", + "index.html": "sha256-d2f21cd3babbd276c90742e3aba4c815ce7833d151ec4660ae5cab6078011eee", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" diff --git a/apps/web/index.html b/apps/web/index.html index 805d41d..41693f3 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,7 +5,7 @@ ProspectOS · Pipeline intelligence - +
@@ -36,6 +36,7 @@ Reports Suppressions Outreach policy + AI providers Score rules @@ -120,6 +121,7 @@

ADD RULE

Add suppression

Explicit confirmation required

REGISTER

Current suppressions

Sign in to load suppressions.

GOVERNANCE

Outreach provider policy

View approved provider status without exposing credentials or secrets.

Sending is disabled by default. This panel is status-only. Provider configuration never creates a send trigger, and no credentials are displayed.
Sign in to load provider policy.
+

SYSTEM CONFIGURATION

AI Provider Settings

Configure the approved Nous Portal and Firecrawl services used by workspace AI research.

Admin-only and write-only credentials. Keys are sent only over the authenticated API, are never displayed or stored in this browser, and updating settings does not start discovery or AI work.

PROVIDER CONFIGURATION

Service connection

Approved providers only
@@ -127,7 +129,7 @@ - - + + diff --git a/apps/web/scripts/final-acceptance.mjs b/apps/web/scripts/final-acceptance.mjs index 36ec1c5..5066fc6 100644 --- a/apps/web/scripts/final-acceptance.mjs +++ b/apps/web/scripts/final-acceptance.mjs @@ -59,12 +59,12 @@ const expectedIds = [ 'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel', 'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'crmPipeline', 'pipelineBoard', 'crmActivity', 'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport', - 'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel', + 'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel', 'aiProviderSettings', 'aiProviderForm', 'aiProviderStatus', 'aiProviderRefreshBtn', 'testAiProviderBtn', 'saveAiProviderBtn', 'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn' ]; const htmlIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map(m => m[1])); check('dom.critical-ids', 'critical operator DOM IDs are present', all(expectedIds, id => htmlIds.has(id)), listMissing(expectedIds, id => htmlIds.has(id)).join(', ')); -const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution']; +const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'ai-provider-settings', 'score-rules', 'score-distribution']; const smokeMarkers = new Set([...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(m => m[1])); check('dom.smoke-markers', 'critical sections expose stable smoke markers', all(expectedMarkers, marker => smokeMarkers.has(marker)), listMissing(expectedMarkers, marker => smokeMarkers.has(marker)).join(', ')); const dynamicMarkers = ['score-breakdown', 'deduplication']; @@ -75,7 +75,7 @@ const routeContracts = [ '/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources', '/api/v1/source-records', '/api/v1/discovery', '/api/v1/discovery-runs', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules', '/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity', - '/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', + '/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', '/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', '/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify' ]; check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', ')); @@ -105,7 +105,7 @@ if (manifestAssets) { const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )]; check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', ')); -check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board'], selector => css.includes(selector))); +check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board', '.ai-provider-grid'], selector => css.includes(selector))); check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector))); const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`; @@ -127,6 +127,8 @@ const secretPatterns = [ ]; const secretHits = secretPatterns.flatMap(pattern => [...combined.matchAll(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`))].map(m => m[0].slice(0, 80))); check('safety.no-hardcoded-secrets', 'frontend source has no obvious hardcoded secrets', secretHits.length === 0, secretHits.join(' | ')); +check('ai-provider.write-only', 'AI provider keys are write-only and cleared after save', all(['type="password"', 'autocomplete="new-password"', "input.value=''", 'input[type="password"]'], token => combined.includes(token)) && !/localStorage[^\n]*(?:nous|firecrawl|api[_-]?key)/i.test(combined)); +check('ai-provider.admin-states', 'AI provider settings expose safe status states and admin-only messaging', all(['/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', 'Not configured', 'Invalid configuration', 'Administrator access required'], token => combined.includes(token))); async function httpChecks(url) { for (const asset of [...new Set([...(manifestAssets || []), ...linkedAssets])].sort()) { diff --git a/apps/web/scripts/smoke-frontend.mjs b/apps/web/scripts/smoke-frontend.mjs index 0f7ed9a..b4ceb4f 100644 --- a/apps/web/scripts/smoke-frontend.mjs +++ b/apps/web/scripts/smoke-frontend.mjs @@ -44,25 +44,25 @@ check('dom.operator-markers', 'operator-critical DOM markers are present', has(h 'id="loginScreen"', 'id="dashboardShell"', 'id="explorer"', 'id="detailPanel"', 'id="reviewQueueCount"', 'id="jobsList"', 'id="sourcesList"', 'id="pipelineBoard"', 'id="interactionState"', 'id="pipelineReport"', 'id="suppressionState"', - 'id="providerPolicyPanel"', 'id="scoreRulesPanel"', 'id="scoreDistributionPanel"' + 'id="providerPolicyPanel"', 'id="aiProviderSettings"', 'id="aiProviderForm"', 'id="aiProviderStatus"', 'id="scoreRulesPanel"', 'id="scoreDistributionPanel"' ])); check('dom.safety-markers', 'safety and approval sections have stable smoke markers', [ 'saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', - 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution' + 'suppression-center', 'outreach-provider-policy', 'ai-provider-settings', 'score-rules', 'score-distribution' ].every(marker => markers.has(marker))); check('dom.required-controls', 'operator controls have stable IDs', [ 'loginEmail', 'loginPassword', 'logoutBtn', 'nextPageBtn', 'bulkVerifyBtn', 'bulkRejectBtn', - 'savedFilterForm', 'pipelineViewToggle', 'interactionForm', 'suppressionForm', 'outreachPolicyRefreshBtn' + 'savedFilterForm', 'pipelineViewToggle', 'interactionForm', 'suppressionForm', 'outreachPolicyRefreshBtn', 'aiProviderRefreshBtn', 'testAiProviderBtn', 'saveAiProviderBtn' ].every(id => htmlIds.has(id)) && has(js, [ 'scanWebsiteBtn', 'extractContactsBtn', 'recalculateScoreBtn', 'generateAiSuggestionBtn', 'createOutreachDraftBtn' ])); check('css.responsive', 'responsive CSS is present for mobile operator layouts', /@media\s*\(max-width\s*:\s*700px\)/.test(css) && - has(css, ['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel'])); + has(css, ['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.ai-provider-grid'])); check('css.layout-contracts', 'critical layout selectors are defined', has(css, [ '.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.pipeline-board', - '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row' + '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row', '.ai-provider-status-row' ])); check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', has(`${html}\n${js}`, [ @@ -79,6 +79,10 @@ check('safety.no-secrets', 'frontend source has no obvious hardcoded secrets', )); check('safety.approval-gated', 'approval is explicitly human-confirmed and non-delivering', has(js, ['window.confirm', 'human_approval:true', 'send:false', 'Approval does not send a message.'])); +check('ai-provider.write-only', 'AI provider credentials are write-only and never browser-persisted', + has(`${html}\n${js}`, ['type="password"', 'autocomplete="new-password"', "input.value=''", 'input[type="password"]']) && !/localStorage[^\n]*(?:nous|firecrawl|api[_-]?key)/i.test(`${html}\n${js}`)); +check('ai-provider.states', 'AI provider status states and admin messaging are represented', + has(`${html}\n${js}`, ['/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', 'Not configured', 'Invalid configuration', 'Administrator access required'])); const routes = [ '/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/businesses', @@ -87,7 +91,7 @@ const routes = [ '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules', '/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity', '/api/v1/suppressions', - '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config' + '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', '/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test' ]; check('routes.contracts', 'operator API route contracts are referenced by the client', routes.every(route => js.includes(route)), routes.filter(route => !js.includes(route)).join(', ')); diff --git a/apps/web/styles.css b/apps/web/styles.css index d605104..b718142 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -236,10 +236,29 @@ td:first-child { color: var(--ink-strong); font-weight: 750; } .website-scan-panel, .domain-intelligence, .contact-extraction-panel { margin-top: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }.website-scan-meta, .domain-check-meta { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; margin: 12px 0; color: var(--muted); font-size: 11px; }.website-scan-meta > span { display: flex; align-items: center; gap: 6px; }.website-scan-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }.website-contact-signals { margin-top: 13px; }.website-contact-signals h5 { margin: 0 0 8px; }.signal-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }.signal { display: grid; gap: 2px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: #fff; font-size: 11px; }.signal b { color: var(--ink); font-size: 10px; }.signal.present b { color: var(--green); }.signal.absent b { color: var(--red); }.website-scan-error, .structured-error { margin: 10px 0; padding: 10px; border: 1px solid #e9b8bd; border-radius: 8px; color: var(--red); background: var(--red-soft); font-size: 12px; }.dns-capabilities { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; margin: 0; }.candidate-domain-panel, .merge-history { margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--line); }.candidate-list, .match-list, .extracted-contact-list { display: grid; gap: 9px; margin-top: 10px; }.candidate-card, .match-card, .extracted-contact { padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.candidate-head, .match-card-head, .extracted-contact-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }.match-confidence, .contact-confidence { color: var(--teal); background: var(--teal-soft); }.match-reasons { margin: 8px 0; padding-left: 17px; color: var(--muted); font-size: 12px; }.extracted-contact-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; margin: 10px 0 0; }.contact-source { grid-column: 1 / -1; }.extracted-contact-facts a { color: var(--teal); }.dedup-panel { margin-top: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }.merge-dialog { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 22px; background: rgba(13,27,42,.62); }.merge-dialog-card { width: min(100%, 540px); padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: 0 25px 80px rgba(13,27,42,.3); }.merge-dialog-card h2 { margin: 2px 0; }.merge-warning { margin: 15px 0; padding: 12px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.merge-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } .lower-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }.add-panel, .csv-panel { padding: 20px; } footer { display: flex; justify-content: space-between; gap: 16px; margin-top: 34px; padding: 20px 0 5px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; } footer a { color: var(--teal); font-weight: 700; } +.ai-provider-settings-section { scroll-margin-top: 90px; } +.ai-provider-safety { margin: 14px 0; padding: 12px 15px; border: 1px solid #c8e2de; border-radius: 9px; color: #216765; background: var(--teal-soft); font-size: 12px; } +.ai-provider-grid { display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(300px, .82fr); gap: 18px; } +.ai-provider-form-panel, .ai-provider-status-panel { min-width: 0; padding: 20px; } +.ai-provider-form-panel form { display: grid; gap: 14px; margin-top: 17px; } +.ai-provider-status-panel { align-self: start; } +.ai-provider-status-list { display: grid; gap: 10px; margin-top: 17px; } +.ai-provider-status-row { padding: 13px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; } +.ai-provider-status-row > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.ai-provider-status-row small { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; } +.ai-provider-status-row .provider-status { margin-top: 10px; } +.ai-provider-status-row p { margin: 9px 0 0; color: var(--muted); font-size: 11px; } +.provider-status.configured, .provider-status.connected { color: var(--green); background: var(--green-soft); } +.provider-status.invalid, .provider-status.error, .provider-status.unavailable { color: var(--red); background: var(--red-soft); } +.provider-status.not-configured { color: var(--amber); background: var(--amber-soft); } +.provider-status-safety { margin: 14px 0 0; color: var(--muted); font-size: 11px; } +.ai-provider-admin-state { margin: 14px 0; } +.ai-provider-admin-state p { margin: 4px 0 0; } + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } @media (max-width: 1100px) { .metrics { grid-template-columns: repeat(3, 1fr); } .filters { grid-template-columns: repeat(3, 1fr); } .filters .search-wrap { grid-column: 1 / -1; } .workspace-grid { grid-template-columns: 1fr; } .detail-panel { min-height: 0; } .reports-grid { grid-template-columns: 1fr 1fr; } } -@media (max-width: 820px) { .sidebar { width: 224px; } .main { margin-left: 224px; } .top-actions { gap: 9px; } .user-identity { display: none; } .jobs-grid, .sources-grid, .crm-two-col, .lower-grid { grid-template-columns: 1fr; } .pipeline-board { grid-template-columns: repeat(4, minmax(180px, 1fr)); } } +@media (max-width: 820px) { .sidebar { width: 224px; } .main { margin-left: 224px; } .top-actions { gap: 9px; } .user-identity { display: none; } .jobs-grid, .sources-grid, .crm-two-col, .lower-grid, .ai-provider-grid { grid-template-columns: 1fr; } .pipeline-board { grid-template-columns: repeat(4, minmax(180px, 1fr)); } } @media(max-width:700px) { .sidebar { width: min(286px, 86vw); transform: translateX(-105%); transition: transform .2s ease; } .sidebar.open { transform: translateX(0); } .main { margin-left: 0; } .mobile-menu { display: block; } .topbar { padding: 0 18px; } .crumb { display: none; } .top-actions { margin-left: auto; } .api-status { display: none; } .content { padding: 28px 16px 20px; } .hero { align-items: flex-start; flex-direction: column; margin-bottom: 22px; } .hero .button { width: 100%; } .metrics { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; } .metric-card { padding: 13px; gap: 9px; } .metric-icon { width: 30px; height: 30px; font-size: 16px; } .metric-card h2 { font-size: 23px; } .filters { grid-template-columns: 1fr; } .filters .search-wrap { grid-column: auto; } .saved-view-controls { grid-template-columns: 1fr; } .saved-view-controls .inline-form { display: flex; } .saved-view-controls .inline-form input { flex: 1; } .form-grid, .score-breakdown-summary, .score-meta { grid-template-columns: 1fr; } .form-footer, .jobs-header, .sources-header, .crm-header, .panel-heading { align-items: stretch; flex-direction: column; } .form-footer .button, .jobs-actions .button, .crm-actions .button, .website-scan-actions .button, .contact-extraction-actions .button { width: 100%; } .table-meta { align-items: flex-start; flex-direction: column; } .table-meta > span:first-child { margin-right: 0; } .source-facts, .provider-policy-row dl { grid-template-columns: repeat(2, 1fr); } .provider-policy-row { grid-template-columns: 1fr; } .provider-policy-row .provider-status { justify-self: start; } .reports-grid { grid-template-columns: 1fr; } .website-scan-grid { grid-template-columns: 1fr; } .signal-list { grid-template-columns: repeat(2, 1fr); } .dns-capabilities { grid-template-columns: repeat(2, 1fr); } .dns-capabilities div:last-child { grid-column: 1 / -1; } .extracted-contact-facts { grid-template-columns: repeat(2, 1fr); } .contact-source { grid-column: 1 / -1; } .score-config-row { grid-template-columns: 1fr 1fr; } .score-config-row strong { grid-column: 1 / -1; } .pipeline-board { grid-template-columns: repeat(4, minmax(210px, 1fr)); } footer { align-items: flex-start; flex-direction: column; } .login-card { padding: 30px 24px; } } /* Direct discovery workspace */