feat(landing): "+ New project" on the picker for authorized users

The server already exposes everything: GET /.profile/access reports
can_create_project (the exact gate POST /.profile/projects enforces), and the
POST creates the folder + seeds its .zddc (creator as admin, title, role
memberships). This wires the landing picker to it:

- On load the picker fetches /.profile/access; if can_create_project, it reveals
  a "+ New project" button next to the Projects heading (hidden otherwise, so we
  never dangle an affordance the server would 404).
- The button opens a dialog mirroring the profile page's create form — name,
  title, and member lists for admins / document controllers / project team /
  guests, plus an advanced ACL-permissions list. It POSTs to /.profile/projects
  and, on success, closes and refreshes the project list so the new project
  appears. Field errors (bad name, 409 duplicate) surface inline.

Server-only by nature (needs the endpoints + auth); offline the access fetch
fails and the button stays hidden.

Also fix a stale landing test: working/staging/reviewing stage links carry a
trailing slash since ec9c9c7 (virtual aggregators 302 on <dir>/); the
assertion still expected the slashless form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 14:42:24 -05:00
parent 1f8b4e4aaa
commit 70c6946e56
4 changed files with 305 additions and 3 deletions

View file

@ -518,3 +518,110 @@ body {
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
} }
/* ── New-project dialog ──────────────────────────────────────────────────── */
.np-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
padding: 3rem 1rem;
}
.np-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
.np-modal__dialog {
position: relative;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 560px;
padding: 1.25rem 1.5rem 1.5rem;
margin: auto;
}
.np-modal__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.np-modal__head h2 { margin: 0; font-size: 1.25rem; }
.np-modal__close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
padding: 0.1rem 0.4rem;
}
.np-modal__close:hover { color: var(--text); }
.np-help { color: var(--text-muted); font-size: 0.85rem; margin: 0 0 1rem; }
.np-field { display: block; margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--text-secondary, var(--text-muted)); }
.np-field input {
display: block;
width: 100%;
margin-top: 0.25rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary, var(--bg));
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.np-err { display: block; color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem; }
.np-grouphdr { font-size: 0.95em; margin: 1rem 0 0.3rem; font-weight: 600; }
.np-sub { font-weight: 400; color: var(--text-muted); font-size: 0.8rem; }
.np-list { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.4rem; }
.np-row { display: flex; gap: 0.4rem; align-items: center; }
.np-row__input { flex: 1; }
.np-row__verbs { flex: 0 0 8rem; }
.np-row input {
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary, var(--bg));
color: var(--text);
font-size: 0.88rem;
box-sizing: border-box;
}
.np-del {
flex: 0 0 auto;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
width: 1.9rem;
height: 1.9rem;
font-size: 1.1rem;
line-height: 1;
}
.np-del:hover { color: var(--danger); border-color: var(--danger); }
.np-add {
background: none;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--primary);
cursor: pointer;
font-size: 0.82rem;
padding: 0.3rem 0.6rem;
}
.np-add:hover { border-color: var(--primary); }
.np-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}

View file

@ -828,6 +828,133 @@
// ── Bootstrap ──────────────────────────────────────────────────────────── // ── Bootstrap ────────────────────────────────────────────────────────────
// ── New project (server-only; gated on can_create_project) ───────────────
// The /.profile endpoints are rooted at the server root, which is where the
// picker lives, so the project base doubles as the API base.
function apiBase() { return location.origin + location.pathname.replace(/\/[^\/]*$/, '/'); }
async function fetchAccess() {
try {
var resp = await fetch(apiBase() + '.profile/access', {
headers: { 'Accept': 'application/json' }, cache: 'no-cache', credentials: 'same-origin',
});
if (!resp.ok) return null;
if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null;
return await resp.json();
} catch (e) { return null; }
}
function npRowFor() {
var row = document.createElement('div'); row.className = 'np-row';
var input = document.createElement('input');
input.type = 'text'; input.className = 'np-row__input'; input.placeholder = 'email or role pattern';
var del = document.createElement('button');
del.type = 'button'; del.className = 'np-del'; del.textContent = '×'; del.setAttribute('aria-label', 'Remove');
row.appendChild(input); row.appendChild(del);
return row;
}
function npPermRow() {
var row = document.createElement('div'); row.className = 'np-row';
var pat = document.createElement('input');
pat.type = 'text'; pat.className = 'np-row__input'; pat.placeholder = 'pattern (email or role)'; pat.dataset.role = 'pattern';
var verbs = document.createElement('input');
verbs.type = 'text'; verbs.className = 'np-row__verbs'; verbs.placeholder = 'verbs e.g. rwc'; verbs.dataset.role = 'verbs';
var del = document.createElement('button');
del.type = 'button'; del.className = 'np-del'; del.textContent = '×'; del.setAttribute('aria-label', 'Remove');
row.appendChild(pat); row.appendChild(verbs); row.appendChild(del);
return row;
}
function npCollectList(field) {
var out = [];
document.querySelectorAll('#npForm .np-list[data-field="' + field + '"] .np-row').forEach(function (r) {
var v = r.querySelector('input').value.trim(); if (v) out.push(v);
});
return out;
}
function npCollectPerms() {
var out = {};
document.querySelectorAll('#npForm .np-list[data-field="acl.permissions"] .np-row').forEach(function (r) {
var pat = r.querySelector('input[data-role="pattern"]').value.trim(); if (!pat) return;
out[pat] = r.querySelector('input[data-role="verbs"]').value.trim();
});
return out;
}
var npWired = false;
function wireNewProjectForm() {
if (npWired) return;
var form = document.getElementById('npForm');
if (!form) return;
npWired = true;
form.querySelectorAll('button.np-add').forEach(function (btn) {
btn.addEventListener('click', function () {
var field = btn.dataset.target;
var host = form.querySelector('.np-list[data-field="' + field + '"]');
host.appendChild(field === 'acl.permissions' ? npPermRow() : npRowFor());
});
});
form.addEventListener('click', function (e) {
if (e.target && e.target.classList && e.target.classList.contains('np-del')) {
e.target.closest('.np-row').remove();
}
});
form.addEventListener('submit', submitNewProject);
}
function openNewProject() {
wireNewProjectForm();
var modal = document.getElementById('newProjectModal');
if (modal) modal.classList.remove('hidden');
var name = document.getElementById('npName');
if (name) name.focus();
}
function closeNewProject() {
var modal = document.getElementById('newProjectModal');
if (modal) modal.classList.add('hidden');
}
function submitNewProject(ev) {
ev.preventDefault();
var nameErr = document.getElementById('npNameErr'); nameErr.textContent = '';
var perms = npCollectPerms();
var body = { parent: '/', name: document.getElementById('npName').value.trim() };
var title = document.getElementById('npTitleInput').value.trim();
if (title) body.title = title;
if (Object.keys(perms).length) body.acl = { permissions: perms };
var admins = npCollectList('admins'); if (admins.length) body.admins = admins;
var dcs = npCollectList('document_controllers'); if (dcs.length) body.document_controllers = dcs;
var team = npCollectList('project_team'); if (team.length) body.project_team = team;
var guests = npCollectList('guests'); if (guests.length) body.guests = guests;
var submitBtn = ev.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
fetch(apiBase() + '.profile/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
}).then(function (r) {
return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function (res) {
if (submitBtn) submitBtn.disabled = false;
if (res.ok) {
closeNewProject();
document.getElementById('npForm').reset();
form_clearLists();
// Surface the newly-created project in the list.
fetchProjects().then(render);
return;
}
try {
var p = JSON.parse(res.text);
if (p && p.errors && p.errors.length) {
nameErr.textContent = p.errors.map(function (e) { return e.field + ': ' + e.message; }).join('; ');
return;
}
} catch (e) { /* not a field-error envelope */ }
nameErr.textContent = 'HTTP ' + res.status + ': ' + res.text;
});
}
function form_clearLists() {
document.querySelectorAll('#npForm .np-list').forEach(function (l) { l.textContent = ''; });
}
async function init() { async function init() {
if (detectMode() === 'project') { if (detectMode() === 'project') {
await renderProjectMode(); await renderProjectMode();
@ -856,6 +983,17 @@
render(); render();
// Show "+ New project" only if the server says this caller may create
// one (same gate the POST endpoint enforces, so the UI never dangles
// an affordance the server would 404).
fetchAccess().then(function (access) {
if (access && access.can_create_project) {
var btn = document.getElementById('newProjectBtn');
if (btn) btn.classList.remove('hidden');
wireNewProjectForm();
}
});
// Wire up keyboard shortcuts in the action-bar input: Enter saves, // Wire up keyboard shortcuts in the action-bar input: Enter saves,
// Escape cancels. // Escape cancels.
var input = document.getElementById('groupNameInput'); var input = document.getElementById('groupNameInput');
@ -891,6 +1029,9 @@
saveGroup: saveGroup, saveGroup: saveGroup,
openSelectedVisible: openSelectedVisible, openSelectedVisible: openSelectedVisible,
dismissWarning: dismissWarning, dismissWarning: dismissWarning,
// New-project dialog.
openNewProject: openNewProject,
closeNewProject: closeNewProject,
// Project-mode entry points (also tested directly). // Project-mode entry points (also tested directly).
detectMode: detectMode, detectMode: detectMode,
renderProjectMode: renderProjectMode, renderProjectMode: renderProjectMode,

View file

@ -85,6 +85,10 @@
<h2>Projects</h2> <h2>Projects</h2>
<span id="projectCount" class="landing-count"></span> <span id="projectCount" class="landing-count"></span>
</div> </div>
<div class="landing-header-actions">
<!-- Shown only when the server reports can_create_project. -->
<button id="newProjectBtn" class="btn btn-primary btn-sm hidden" onclick="LandingApp.openNewProject()">+ New project</button>
</div>
</div> </div>
<div id="projectListContainer" class="project-list-container"> <div id="projectListContainer" class="project-list-container">
@ -94,6 +98,53 @@
</div> </div>
</div><!-- /pickerView --> </div><!-- /pickerView -->
<!-- New-project dialog. Mirrors the profile page's create form; POSTs to
/.profile/projects, which gates on the create verb at the root. -->
<div id="newProjectModal" class="np-modal hidden" role="dialog" aria-modal="true" aria-labelledby="npHeading">
<div class="np-modal__backdrop" onclick="LandingApp.closeNewProject()"></div>
<div class="np-modal__dialog">
<div class="np-modal__head">
<h2 id="npHeading">Create new project</h2>
<button type="button" class="np-modal__close" onclick="LandingApp.closeNewProject()" aria-label="Close">&times;</button>
</div>
<p class="np-help">Creates a top-level project folder. You're recorded as its creator and added to its admins automatically. Assign members to the project roles below — one email (or role pattern) per row.</p>
<form id="npForm" autocomplete="off">
<label class="np-field">Name
<input type="text" id="npName" maxlength="64" placeholder="e.g. Site-3" required>
<span class="np-err" id="npNameErr"></span>
</label>
<label class="np-field">Title (optional)
<input type="text" id="npTitleInput" maxlength="200" placeholder="Human-readable project title">
</label>
<h3 class="np-grouphdr">Admins <span class="np-sub">full control (you're already an admin)</span></h3>
<div class="np-list" data-field="admins"></div>
<button type="button" class="np-add" data-target="admins">+ Add admin</button>
<h3 class="np-grouphdr">Document controllers <span class="np-sub">manage filing &amp; records — read / write / create / delete</span></h3>
<div class="np-list" data-field="document_controllers"></div>
<button type="button" class="np-add" data-target="document_controllers">+ Add document controller</button>
<h3 class="np-grouphdr">Project team <span class="np-sub">contribute documents — read / write / create</span></h3>
<div class="np-list" data-field="project_team"></div>
<button type="button" class="np-add" data-target="project_team">+ Add team member</button>
<h3 class="np-grouphdr">Guests <span class="np-sub">read-only access</span></h3>
<div class="np-list" data-field="guests"></div>
<button type="button" class="np-add" data-target="guests">+ Add guest</button>
<h3 class="np-grouphdr">Advanced — ACL permissions <span class="np-sub">pattern → verbs (r w c d a); empty verbs = explicit deny</span></h3>
<div class="np-list" data-field="acl.permissions"></div>
<button type="button" class="np-add" data-target="acl.permissions">+ Add permission</button>
<div class="np-actions">
<button type="button" class="btn btn-secondary" onclick="LandingApp.closeNewProject()">Cancel</button>
<button type="submit" class="btn btn-primary">Create project</button>
</div>
</form>
</div>
</div>
<!-- Project mode (/<project>). Stage cards + MDL section. Shown <!-- Project mode (/<project>). Stage cards + MDL section. Shown
by landing.js when location.pathname is a single segment. --> by landing.js when location.pathname is a single segment. -->
<div id="projectView" class="hidden"> <div id="projectView" class="hidden">

View file

@ -295,11 +295,14 @@ test.describe('Landing project mode', () => {
staging: document.getElementById('stageStaging').getAttribute('href'), staging: document.getElementById('stageStaging').getAttribute('href'),
reviewing: document.getElementById('stageReviewing').getAttribute('href'), reviewing: document.getElementById('stageReviewing').getAttribute('href'),
})); }));
// working/staging/reviewing are virtual aggregators served at <dir>/ —
// the trailing slash is required so the server 302s to the canonical
// form (see ec9c9c7). archive is a real dir linked without it.
expect(stageHrefs).toEqual({ expect(stageHrefs).toEqual({
archive: '/Project-1/archive', archive: '/Project-1/archive',
working: '/Project-1/working', working: '/Project-1/working/',
staging: '/Project-1/staging', staging: '/Project-1/staging/',
reviewing: '/Project-1/reviewing', reviewing: '/Project-1/reviewing/',
}); });
}); });