Projects Udpate

This commit is contained in:
Rokojori 2026-07-14 15:05:04 +02:00
parent 5bcabe2c67
commit 99f6f787ec
8 changed files with 507 additions and 138 deletions

View File

@ -81,12 +81,35 @@ project-editor .add-member-form {
} }
project-editor .add-member-form input { project-editor .add-member-form input {
width: 80px; flex: 1;
min-width: 160px;
padding: 0.4rem; padding: 0.4rem;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
} }
project-editor .member-email {
flex: 1;
font-size: 0.9rem;
}
project-editor .member-role {
font-size: 0.8rem;
color: #777;
padding: 0.1rem 0.4rem;
border: 1px solid #ddd;
border-radius: 3px;
}
project-editor .member-owner {
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid #f0f0f0;
}
project-editor select { project-editor select {
padding: 0.4rem; padding: 0.4rem;
border: 1px solid #ccc; border: 1px solid #ccc;

View File

@ -1,5 +1,10 @@
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
interface CurrentUser {
userId: string;
email: string;
}
interface Project { interface Project {
id: string; id: string;
name: string; name: string;
@ -15,12 +20,16 @@ interface ProjectMember {
} }
class ProjectEditor extends HTMLElement { class ProjectEditor extends HTMLElement {
connectedCallback(): void { private currentUser: CurrentUser | null = null;
async connectedCallback(): Promise<void> {
const res = await fetch( '/api/auth/me' );
if ( res.ok ) this.currentUser = await res.json() as CurrentUser;
this.render(); this.render();
} }
async render(): Promise<void> { async render(): Promise<void> {
const res = await fetch('/api/projects'); const res = await fetch( '/api/projects' );
const projects = await res.json() as Project[]; const projects = await res.json() as Project[];
this.innerHTML = ` this.innerHTML = `
@ -31,111 +40,119 @@ class ProjectEditor extends HTMLElement {
<button type="submit">Create</button> <button type="submit">Create</button>
</form> </form>
<ul class="project-list"> <ul class="project-list">
${projects.map(p => ` ${ projects.map( p => `
<li data-id="${p.id}"> <li data-id="${ p.id }">
<div class="project-header"> <div class="project-header">
<strong>${p.name}</strong> <strong>${ p.name }</strong>
<a class="btn-edit" href="/editor.html?project=${p.id}&name=${encodeURIComponent(p.name)}">Edit</a> <a class="btn-edit" href="/editor.html?project=${ p.id }&name=${ encodeURIComponent( p.name ) }">Edit</a>
<button class="btn-members" data-id="${p.id}">Members</button> <button class="btn-members" data-id="${ p.id }">Members</button>
<button class="btn-delete" data-id="${p.id}" data-name="${p.name}">Delete</button> <button class="btn-delete" data-id="${ p.id }" data-name="${ p.name }">Delete</button>
</div> </div>
<div class="members-panel" data-project="${p.id}" style="display:none"></div> <div class="members-panel" data-project="${ p.id }" data-owner="${ p.owner_id }" style="display:none"></div>
</li> </li>
`).join('')} ` ).join( '' ) }
</ul> </ul>
</div> </div>
`; `;
this.querySelector('.create-form')!.addEventListener('submit', async (e: Event) => { this.querySelector( '.create-form' )!.addEventListener( 'submit', async ( e: Event ) => {
e.preventDefault(); e.preventDefault();
const form = e.target as HTMLFormElement; const form = e.target as HTMLFormElement;
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim(); const name = ( form.elements.namedItem( 'name' ) as HTMLInputElement ).value.trim();
const res = await fetch('/api/projects', { const res = await fetch( '/api/projects', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }) body: JSON.stringify( { name } )
}); } );
if (res.ok) this.render(); if ( res.ok ) this.render();
}); } );
this.querySelectorAll('.btn-delete').forEach(btn => { this.querySelectorAll( '.btn-delete' ).forEach( btn => {
btn.addEventListener('click', async ( e ) => { btn.addEventListener( 'click', async () => {
console.log( "Clicking:", e );
const el = btn as HTMLElement; const el = btn as HTMLElement;
const ok = await showConfirmDialog({ const ok = await showConfirmDialog( {
icon: '🗑', icon: '🗑',
title: 'Delete Project', title: 'Delete Project',
message: `Delete "${el.dataset.name}"? This cannot be undone.`, message: `Delete "${ el.dataset.name }"? This cannot be undone.`,
confirmLabel: 'Delete', confirmLabel: 'Delete',
cancelLabel: 'Cancel', cancelLabel: 'Cancel',
danger: true danger: true
}); } );
if ( !ok ) return;
console.log( "Result:", ok ); await fetch( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } );
if (!ok) return;
await fetch(`/api/projects/${el.dataset.id}`, { method: 'DELETE' });
this.render(); this.render();
}); } );
}); } );
this.querySelectorAll('.btn-members').forEach(btn => { this.querySelectorAll( '.btn-members' ).forEach( btn => {
btn.addEventListener('click', () => this.toggleMembers((btn as HTMLElement).dataset.id!)); btn.addEventListener( 'click', () => this.toggleMembers( ( btn as HTMLElement ).dataset.id! ) );
}); } );
} }
async toggleMembers(projectId: string): Promise<void> { async toggleMembers( projectId: string ): Promise<void> {
const panel = this.querySelector(`.members-panel[data-project="${projectId}"]`) as HTMLElement; const panel = this.querySelector( `.members-panel[data-project="${ projectId }"]` ) as HTMLElement;
if (panel.style.display !== 'none') { panel.style.display = 'none'; return; } if ( panel.style.display !== 'none' ) { panel.style.display = 'none'; return; }
panel.style.display = 'block'; panel.style.display = 'block';
await this.loadMembers(projectId, panel); await this.loadMembers( projectId, panel );
} }
async loadMembers(projectId: string, panel: HTMLElement): Promise<void> { async loadMembers( projectId: string, panel: HTMLElement ): Promise<void> {
const res = await fetch(`/api/projects/${projectId}/members`); const res = await fetch( `/api/projects/${ projectId }/members` );
const members = await res.json() as ProjectMember[]; const members = await res.json() as ProjectMember[];
const ownerId = panel.dataset.owner ?? '';
const isOwner = this.currentUser?.userId === ownerId;
const ownerLabel = isOwner
? `${ this.currentUser!.email } <span class="member-role">owner</span>`
: `${ ownerId } <span class="member-role">owner</span>`;
const memberRows = members.map( m => `
<li>
<span class="member-email">${ m.member_id }</span>
<span class="member-role">${ m.role }</span>
${ isOwner ? `<button class="btn-remove-member" data-pid="${ projectId }" data-mid="${ m.id }">Remove</button>` : '' }
</li>
` ).join( '' );
panel.innerHTML = ` panel.innerHTML = `
<ul class="member-list"> <ul class="member-list">
${members.map(m => ` <li class="member-owner">${ ownerLabel }</li>
<li>${m.member_type}: ${m.member_id} ${m.role} ${ memberRows || '' }
<button class="btn-remove-member" data-pid="${projectId}" data-mid="${m.id}">Remove</button> ${ members.length === 0 ? '<li class="empty">No additional members</li>' : '' }
</li>
`).join('') || '<li class="empty">No members</li>'}
</ul> </ul>
<form class="add-member-form"> ${ isOwner ? `
<select name="member_type"> <form class="add-member-form">
<option value="user">User ID</option> <input name="email" type="email" placeholder="Email address" required>
<option value="group">Group ID</option> <select name="role">
</select> <option value="viewer">Viewer</option>
<input name="member_id" type="number" placeholder="ID" required> <option value="editor">Editor</option>
<select name="role"> <option value="admin">Admin</option>
<option value="viewer">Viewer</option> </select>
<option value="editor">Editor</option> <button type="submit">Add</button>
<option value="admin">Admin</option> </form>
</select> ` : '' }
<button type="submit">Add</button>
</form>
`; `;
panel.querySelectorAll('.btn-remove-member').forEach(btn => { panel.querySelectorAll( '.btn-remove-member' ).forEach( btn => {
btn.addEventListener('click', async () => { btn.addEventListener( 'click', async () => {
const el = btn as HTMLElement; const el = btn as HTMLElement;
await fetch(`/api/projects/${el.dataset.pid}/members/${el.dataset.mid}`, { method: 'DELETE' }); await fetch( `/api/projects/${ el.dataset.pid }/members/${ el.dataset.mid }`, { method: 'DELETE' } );
await this.loadMembers(projectId, panel); await this.loadMembers( projectId, panel );
}); } );
}); } );
(panel.querySelector('.add-member-form') as HTMLFormElement).addEventListener('submit', async (e: Event) => { const addForm = panel.querySelector( '.add-member-form' ) as HTMLFormElement | null;
addForm?.addEventListener( 'submit', async ( e: Event ) => {
e.preventDefault(); e.preventDefault();
const data = Object.fromEntries(new FormData(e.target as HTMLFormElement)); const data = Object.fromEntries( new FormData( e.target as HTMLFormElement ) );
await fetch(`/api/projects/${projectId}/members`, { await fetch( `/api/projects/${ projectId }/members`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify( data )
}); } );
await this.loadMembers(projectId, panel); await this.loadMembers( projectId, panel );
}); } );
} }
} }
customElements.define('project-editor', ProjectEditor);
customElements.define( 'project-editor', ProjectEditor );

View File

@ -26,8 +26,8 @@
inkscape:document-units="px" inkscape:document-units="px"
showgrid="false" showgrid="false"
inkscape:zoom="0.20890503" inkscape:zoom="0.20890503"
inkscape:cx="1969.7946" inkscape:cx="2089.4662"
inkscape:cy="1179.962" inkscape:cy="1524.6162"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="1017" inkscape:window-height="1017"
inkscape:window-x="-8" inkscape:window-x="-8"
@ -35,6 +35,34 @@
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="g17" /><defs inkscape:current-layer="g17" /><defs
id="defs2"><linearGradient id="defs2"><linearGradient
id="linearGradient19"
inkscape:collect="never"><stop
style="stop-color:#040bff;stop-opacity:0;"
offset="0.37807184"
id="stop19" /><stop
style="stop-color:#28ece5;stop-opacity:0.26666668;"
offset="0.71330941"
id="stop22" /><stop
style="stop-color:#15ff04;stop-opacity:0.5333333;"
offset="0.86158413"
id="stop21" /><stop
style="stop-color:#f8de48;stop-opacity:1;"
offset="0.93746954"
id="stop23" /><stop
style="stop-color:#ff0404;stop-opacity:0;"
offset="1"
id="stop20" /></linearGradient><linearGradient
id="linearGradient3"
inkscape:collect="never"><stop
style="stop-color:#0495ff;stop-opacity:0;"
offset="0"
id="stop3" /><stop
style="stop-color:#0495ff;stop-opacity:1;"
offset="0.46630496"
id="stop15" /><stop
style="stop-color:#0495ff;stop-opacity:0;"
offset="1"
id="stop4" /></linearGradient><linearGradient
id="linearGradient12" id="linearGradient12"
inkscape:collect="never"><stop inkscape:collect="never"><stop
style="stop-color:#00bcf5;stop-opacity:1;" style="stop-color:#00bcf5;stop-opacity:1;"
@ -157,7 +185,102 @@
height="2.6460531"><feGaussianBlur height="2.6460531"><feGaussianBlur
inkscape:collect="always" inkscape:collect="always"
stdDeviation="329.39922" stdDeviation="329.39922"
id="feGaussianBlur16" /></filter></defs><g id="feGaussianBlur16" /></filter><linearGradient
inkscape:collect="never"
xlink:href="#linearGradient3"
id="linearGradient4"
x1="564.68774"
y1="994.08865"
x2="2793.1636"
y2="994.08865"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.7990724,0,0,0.1301594,-1341.5831,915.47103)" /><linearGradient
inkscape:collect="never"
xlink:href="#linearGradient3"
id="linearGradient15"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.7990724,0,0,0.03619034,-109.50713,1057.1184)"
x1="564.68774"
y1="994.08865"
x2="2793.1636"
y2="994.08865" /><linearGradient
inkscape:collect="never"
xlink:href="#linearGradient3"
id="linearGradient16"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.7990724,0,0,0.03619034,-1260.748,958.98764)"
x1="564.68774"
y1="994.08865"
x2="2793.1636"
y2="994.08865" /><linearGradient
inkscape:collect="never"
xlink:href="#linearGradient5"
id="linearGradient17"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.4051037,0,0,1.4051038,-1136.1343,-483.68581)"
x1="2094.8511"
y1="824.97723"
x2="2175.8127"
y2="1376.4214" /><radialGradient
inkscape:collect="never"
xlink:href="#linearGradient19"
id="radialGradient20"
cx="1119.998"
cy="950.70941"
fx="1119.998"
fy="950.70941"
r="624.87445"
gradientTransform="matrix(1,0,0,1.0015514,816.16034,91.868989)"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="never"
xlink:href="#linearGradient19"
id="radialGradient23"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.50283735,0,0,0.50361745,772.23007,617.91495)"
cx="1119.998"
cy="950.70941"
fx="1119.998"
fy="950.70941"
r="624.87445" /><radialGradient
inkscape:collect="never"
xlink:href="#linearGradient19"
id="radialGradient24"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75180429,0,0,0.75297064,658.53439,589.08112)"
cx="1119.998"
cy="950.70941"
fx="1119.998"
fy="950.70941"
r="624.87445" /><filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter24"
x="-0.166101"
y="-0.16584373"
width="1.332202"
height="1.3316875"><feGaussianBlur
inkscape:collect="always"
stdDeviation="65.026231"
id="feGaussianBlur24" /></filter><linearGradient
inkscape:collect="never"
xlink:href="#linearGradient3"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.55162383,0,0,0.03990891,-214.34719,-1814.9918)"
x1="564.68774"
y1="994.08865"
x2="2793.1636"
y2="994.08865" /><radialGradient
inkscape:collect="never"
xlink:href="#linearGradient19"
id="radialGradient25"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75180429,0,0,0.75297064,811.71404,129.54216)"
cx="1119.998"
cy="950.70941"
fx="1119.998"
fy="950.70941"
r="624.87445" /></defs><g
inkscape:label="Content" inkscape:label="Content"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1" id="layer1"
@ -274,4 +397,91 @@
id="tspan14" id="tspan14"
x="1480.9904" x="1480.9904"
y="1344.5404" y="1344.5404"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:'Kode Mono';-inkscape-font-specification:'Kode Mono Bold';fill:#00cbf5;fill-opacity:1;stroke-width:16.8787">COMPUTER! ZOOM IN!</tspan></text></g></g></g></svg> style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:'Kode Mono';-inkscape-font-specification:'Kode Mono Bold';fill:#00cbf5;fill-opacity:1;stroke-width:16.8787">COMPUTER! ZOOM IN!</tspan></text></g><rect
style="opacity:0.208678;mix-blend-mode:screen;fill:url(#linearGradient4);stroke:none;stroke-width:9.67817;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect3"
width="4009.1892"
height="51.572651"
x="-325.66898"
y="1019.0746"
ry="0.16230841" /><rect
style="opacity:0.053719;mix-blend-mode:screen;fill:url(#linearGradient15);stroke:none;stroke-width:5.10331;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect15"
width="4009.1892"
height="14.339585"
x="906.40698"
y="1085.9249"
ry="0.045129254" /><rect
style="opacity:0.163223;mix-blend-mode:screen;fill:url(#linearGradient16);stroke:none;stroke-width:5.10331;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect16"
width="4009.1892"
height="14.339585"
x="-244.83385"
y="987.79419"
ry="0.045129254" /><text
xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:900;font-stretch:normal;font-size:642.307px;line-height:1.4;font-family:Barlow;-inkscape-font-specification:'Barlow Heavy Italic';writing-mode:lr-tb;direction:ltr;display:inline;opacity:0.0702479;mix-blend-mode:screen;fill:#04ff1d;fill-opacity:1;stroke-width:16.0577;stroke-linecap:round;stroke-linejoin:round"
x="1045.7648"
y="1203.9724"
id="text17"
transform="matrix(0.95321043,0,-0.10226778,1.0490863,0,0)"><tspan
sodipodi:role="line"
id="tspan17"
x="1045.7648"
y="1203.9724"
style="font-style:italic;font-variant:normal;font-weight:900;font-stretch:normal;font-family:Barlow;-inkscape-font-specification:'Barlow Heavy Italic';fill:#04ff1d;fill-opacity:1;stroke-width:16.0577">ROJECT</tspan></text><text
xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:900;font-stretch:normal;font-size:650.011px;line-height:1.4;font-family:Barlow;-inkscape-font-specification:'Barlow Heavy Italic';writing-mode:lr-tb;direction:ltr;display:inline;opacity:0.103306;mix-blend-mode:screen;fill:#ea04ff;fill-opacity:1;stroke-width:16.2503;stroke-linecap:round;stroke-linejoin:round"
x="1042.5992"
y="1194.2871"
id="text18"
transform="matrix(0.95321043,0,-0.10226778,1.0490863,0,0)"><tspan
sodipodi:role="line"
id="tspan18"
x="1042.5992"
y="1194.2871"
style="font-style:italic;font-variant:normal;font-weight:900;font-stretch:normal;font-family:Barlow;-inkscape-font-specification:'Barlow Heavy Italic';fill:#ea04ff;fill-opacity:1;stroke-width:16.2503">ROJECT</tspan></text><text
xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:900;font-stretch:normal;font-size:650.011px;line-height:1.4;font-family:Barlow;-inkscape-font-specification:'Barlow Heavy Italic';writing-mode:lr-tb;direction:ltr;display:inline;opacity:0.0268595;mix-blend-mode:screen;fill:#f7ff04;fill-opacity:1;stroke-width:16.2503;stroke-linecap:round;stroke-linejoin:round"
x="1056.5159"
y="1201.1315"
id="text19"
transform="matrix(0.95321043,0,-0.10226778,1.0490863,0,0)"><tspan
sodipodi:role="line"
id="tspan19"
x="1056.5159"
y="1201.1315"
style="font-style:italic;font-variant:normal;font-weight:900;font-stretch:normal;font-family:Barlow;-inkscape-font-specification:'Barlow Heavy Italic';fill:#f7ff04;fill-opacity:1;stroke-width:16.2503">ROJECT</tspan></text><ellipse
style="opacity:0.01652893;mix-blend-mode:screen;fill:url(#radialGradient20);fill-opacity:1;stroke:none;stroke-width:18.4735;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path19"
cx="1936.1583"
cy="1044.0532"
rx="624.87445"
ry="625.84381" /><ellipse
style="opacity:0.0785124;mix-blend-mode:screen;fill:url(#radialGradient23);fill-opacity:1;stroke:none;stroke-width:9.28917;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="ellipse23"
cx="1335.4069"
cy="1096.7087"
rx="314.21021"
ry="314.69763" /><ellipse
style="opacity:0.25413223;mix-blend-mode:screen;fill:url(#radialGradient24);fill-opacity:1;stroke:none;stroke-width:13.8885;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter24)"
id="ellipse24"
cx="1500.5537"
cy="1304.9374"
rx="469.78329"
ry="470.51205" /><rect
style="opacity:0.208678;mix-blend-mode:screen;fill:url(#linearGradient24);stroke:none;stroke-width:2.96748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect24"
width="1229.2803"
height="15.812984"
x="97.148048"
y="-1783.2252"
ry="0.049766306"
transform="rotate(100.78149)" /><ellipse
style="opacity:0.10743802;mix-blend-mode:screen;fill:url(#radialGradient25);fill-opacity:1;stroke:none;stroke-width:13.8885;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter24)"
id="ellipse25"
cx="1653.7334"
cy="845.39844"
rx="469.78329"
ry="470.51205"
transform="matrix(0.34298317,0,0,0.34298317,1105.6782,569.80159)" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -24,15 +24,15 @@
inkscape:deskcolor="#333333" inkscape:deskcolor="#333333"
inkscape:document-units="px" inkscape:document-units="px"
showgrid="false" showgrid="false"
inkscape:zoom="9.4539622" inkscape:zoom="3.3424804"
inkscape:cx="-14.755718" inkscape:cx="-46.821516"
inkscape:cy="24.910191" inkscape:cy="45.923979"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="1017" inkscape:window-height="1017"
inkscape:window-x="-8" inkscape:window-x="-8"
inkscape:window-y="-8" inkscape:window-y="-8"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="svg5" /><defs inkscape:current-layer="g26" /><defs
id="defs2"><clipPath id="defs2"><clipPath
clipPathUnits="userSpaceOnUse" clipPathUnits="userSpaceOnUse"
id="clipPath7940"><rect id="clipPath7940"><rect

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,46 @@
import { Project, ProjectMember, projects, projectMembers } from './db';
import { JwtUser } from './middleware/auth';
export function isOwner( project: Project, user: JwtUser ): boolean
{
return project.owner_id === user.userId;
}
function memberMatchesUser( member: ProjectMember, user: JwtUser ): boolean
{
if ( member.member_type !== 'user' ) return false;
// Interim (Option B): member_id stores the email address.
// When migrating to ID-based lookup, change this one line to: member.member_id === user.userId
return member.member_id === user.email;
}
export function getMemberRole( members: ProjectMember[], user: JwtUser ): string | null
{
return members.find( m => memberMatchesUser( m, user ) )?.role ?? null;
}
export function canView( project: Project, members: ProjectMember[], user: JwtUser ): boolean
{
return isOwner( project, user ) || getMemberRole( members, user ) !== null;
}
export function canEdit( project: Project, members: ProjectMember[], user: JwtUser ): boolean
{
if ( isOwner( project, user ) ) return true;
const role = getMemberRole( members, user );
return role === 'editor' || role === 'admin';
}
export type AccessResult =
| { ok: true }
| { ok: false; status: number; error: string };
export function checkAccess( projectId: string, user: JwtUser, mode: 'view' | 'edit' ): AccessResult
{
const project = projects.findById( projectId );
if ( !project ) return { ok: false, status: 404, error: 'Not found' };
const members = projectMembers.forProject( projectId );
const allowed = mode === 'view' ? canView( project, members, user ) : canEdit( project, members, user );
if ( !allowed ) return { ok: false, status: 403, error: 'Forbidden' };
return { ok: true };
}

View File

@ -1,38 +1,54 @@
import { Router } from 'express'; import { Router } from 'express';
import { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage'; import { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from '../middleware/auth';
import { checkAccess } from '../projectAccess';
const router = Router(); const router = Router();
router.use(requireAuth); router.use( requireAuth );
router.get('/:projectId/tree', (req, res) => { router.get( '/:projectId/tree', ( req, res ) =>
res.json(getFileTree(req.params.projectId)); {
}); const access = checkAccess( req.params.projectId, req.user!, 'view' );
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
res.json( getFileTree( req.params.projectId ) );
} );
router.get('/:projectId/*', (req, res) => { router.get( '/:projectId/*', ( req, res ) =>
const filePath = (req.params as Record<string, string>)[0]; {
const content = readProjectFile(req.params.projectId, filePath); const access = checkAccess( req.params.projectId, req.user!, 'view' );
if (content === null) { res.status(404).json({ error: 'Not found' }); return; } if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
res.type('text/plain').send(content); const filePath = ( req.params as Record<string, string> )[ 0 ];
}); const content = readProjectFile( req.params.projectId, filePath );
if ( content === null ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
res.type( 'text/plain' ).send( content );
} );
router.post('/:projectId/create-file', (req, res) => { router.post( '/:projectId/create-file', ( req, res ) =>
{
const access = checkAccess( req.params.projectId, req.user!, 'edit' );
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
const { path: filePath } = req.body as { path: string }; const { path: filePath } = req.body as { path: string };
if (!filePath) { res.status(400).json({ error: 'path required' }); return; } if ( !filePath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
const ok = createProjectFile(req.params.projectId, filePath); const ok = createProjectFile( req.params.projectId, filePath );
if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; } if ( !ok ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; }
res.json({ ok: true }); res.json( { ok: true } );
}); } );
router.post('/:projectId/create-directory', (req, res) => { router.post( '/:projectId/create-directory', ( req, res ) =>
{
const access = checkAccess( req.params.projectId, req.user!, 'edit' );
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
const { path: dirPath } = req.body as { path: string }; const { path: dirPath } = req.body as { path: string };
if (!dirPath) { res.status(400).json({ error: 'path required' }); return; } if ( !dirPath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
const ok = createProjectDirectory(req.params.projectId, dirPath); const ok = createProjectDirectory( req.params.projectId, dirPath );
if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; } if ( !ok ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; }
res.json({ ok: true }); res.json( { ok: true } );
}); } );
router.post( '/:projectId/rename', ( req, res ) => { router.post( '/:projectId/rename', ( req, res ) =>
{
const access = checkAccess( req.params.projectId, req.user!, 'edit' );
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
const { path: oldPath, newName } = req.body as { path: string; newName: string }; const { path: oldPath, newName } = req.body as { path: string; newName: string };
if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; } if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; }
const ok = renameProjectEntry( req.params.projectId, oldPath, newName ); const ok = renameProjectEntry( req.params.projectId, oldPath, newName );
@ -40,7 +56,10 @@ router.post( '/:projectId/rename', ( req, res ) => {
res.json( { ok: true } ); res.json( { ok: true } );
} ); } );
router.post( '/:projectId/delete', ( req, res ) => { router.post( '/:projectId/delete', ( req, res ) =>
{
const access = checkAccess( req.params.projectId, req.user!, 'edit' );
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
const { path: targetPath } = req.body as { path: string }; const { path: targetPath } = req.body as { path: string };
if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
const ok = deleteProjectEntry( req.params.projectId, targetPath ); const ok = deleteProjectEntry( req.params.projectId, targetPath );
@ -48,12 +67,15 @@ router.post( '/:projectId/delete', ( req, res ) => {
res.json( { ok: true } ); res.json( { ok: true } );
} ); } );
router.put('/:projectId/*', (req, res) => { router.put( '/:projectId/*', ( req, res ) =>
const filePath = (req.params as Record<string, string>)[0]; {
if (typeof req.body !== 'string') { res.status(400).json({ error: 'Content must be text' }); return; } const access = checkAccess( req.params.projectId, req.user!, 'edit' );
const ok = writeProjectFile(req.params.projectId, filePath, req.body); if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
if (!ok) { res.status(403).json({ error: 'Invalid path' }); return; } const filePath = ( req.params as Record<string, string> )[ 0 ];
res.json({ ok: true }); if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }
}); const ok = writeProjectFile( req.params.projectId, filePath, req.body );
if ( !ok ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
res.json( { ok: true } );
} );
export default router; export default router;

View File

@ -2,41 +2,66 @@ import { Router } from 'express';
import { projects, projectMembers } from '../db'; import { projects, projectMembers } from '../db';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from '../middleware/auth';
import { createProjectStorage } from '../storage'; import { createProjectStorage } from '../storage';
import { RJLog } from '../../library-ts/node/log/RJLog'; import { isOwner, canView, getMemberRole } from '../projectAccess';
const router = Router(); const router = Router();
router.use(requireAuth); router.use( requireAuth );
router.get('/', (_req, res) => res.json(projects.all())); router.get( '/', ( req, res ) =>
{
const user = req.user!;
const visible = projects.all().filter( p =>
{
if ( isOwner( p, user ) ) return true;
return getMemberRole( projectMembers.forProject( p.id ), user ) !== null;
} );
res.json( visible );
} );
router.post('/', (req, res) => { router.post( '/', ( req, res ) =>
{
const { name } = req.body as { name?: string }; const { name } = req.body as { name?: string };
if (!name) { res.status(400).json({ error: 'Name required' }); return; } if ( !name ) { res.status( 400 ).json( { error: 'Name required' } ); return; }
const project = projects.create({ name, owner_id: req.user!.userId }); const project = projects.create( { name, owner_id: req.user!.userId } );
createProjectStorage(project.id); createProjectStorage( project.id );
res.json(project); res.json( project );
}); } );
router.delete('/:id', (req, res) => { router.delete( '/:id', ( req, res ) =>
{
const project = projects.findById( req.params.id );
if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
projects.delete( req.params.id );
res.json( { ok: true } );
} );
RJLog.log( "Deleting:", req.params.id ); router.get( '/:id/members', ( req, res ) =>
projects.delete(req.params.id); {
res.json({ ok: true }); const project = projects.findById( req.params.id );
}); if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
const members = projectMembers.forProject( req.params.id );
if ( !canView( project, members, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
res.json( members );
} );
router.get('/:id/members', (req, res) => { router.post( '/:id/members', ( req, res ) =>
res.json(projectMembers.forProject(req.params.id)); {
}); const project = projects.findById( req.params.id );
if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
const { email, role } = req.body as { email?: string; role?: string };
if ( !email ) { res.status( 400 ).json( { error: 'email required' } ); return; }
res.json( projectMembers.add( { project_id: req.params.id, member_type: 'user', member_id: email, role: role ?? 'viewer' } ) );
} );
router.post('/:id/members', (req, res) => { router.delete( '/:id/members/:memberId', ( req, res ) =>
const { member_type, member_id, role } = req.body as { member_type?: 'user' | 'group'; member_id?: string; role?: string }; {
if (!member_type || !member_id) { res.status(400).json({ error: 'member_type and member_id required' }); return; } const project = projects.findById( req.params.id );
res.json(projectMembers.add({ project_id: req.params.id, member_type, member_id, role: role ?? 'viewer' })); if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
}); if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
projectMembers.remove( req.params.memberId );
router.delete('/:id/members/:memberId', (req, res) => { res.json( { ok: true } );
projectMembers.remove(req.params.memberId); } );
res.json({ ok: true });
});
export default router; export default router;

View File

@ -22,6 +22,32 @@
<div class="lane"> <div class="lane">
<div class="lane-header">MVP</div> <div class="lane-header">MVP</div>
<task-item class="blue hide-content">
<task-title>Add rokojori-auth lookup-email endpoint</task-title>
<task-content>
Project members are currently stored by email (Option B interim). To migrate to
user-ID-based membership the two services need a server-to-server lookup call.
In rokojori-auth:
— Add POST /api/auth/lookup-email route
— Body: { email: string }
— Auth: Authorization: Bearer &lt;SERVICE_SECRET&gt; header (shared env var, not a user JWT)
— Returns { id, email } on success, 404 if no account exists for that email
— Add SERVICE_SECRET to rokojori-auth .env and its deployment config
In Roject:
— Add ROJECT_SERVICE_SECRET to .env (must match rokojori-auth)
— In POST /api/projects/:id/members: call account.rokojori.com/api/auth/lookup-email,
receive the user ID, store member_id as the user ID instead of the email
— In source/server/projectAccess.ts change the one line in memberMatchesUser()
from member.member_id === user.email
to member.member_id === user.userId
— Write a one-off migration script to rewrite existing project_members records:
for each member row, call lookup-email with the stored email, replace member_id
with the returned user ID
</task-content>
</task-item>
<task-item class="blue hide-content"> <task-item class="blue hide-content">
<task-title>Local Git Repository Integration</task-title> <task-title>Local Git Repository Integration</task-title>
<task-content> <task-content>