Projects Udpate
This commit is contained in:
parent
5bcabe2c67
commit
99f6f787ec
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,7 +20,11 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +48,7 @@ class ProjectEditor extends HTMLElement {
|
||||||
<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>
|
||||||
|
|
@ -59,9 +68,7 @@ class ProjectEditor extends HTMLElement {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
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: '🗑',
|
||||||
|
|
@ -71,8 +78,6 @@ class ProjectEditor extends HTMLElement {
|
||||||
cancelLabel: 'Cancel',
|
cancelLabel: 'Cancel',
|
||||||
danger: true
|
danger: true
|
||||||
} );
|
} );
|
||||||
|
|
||||||
console.log( "Result:", ok );
|
|
||||||
if ( !ok ) return;
|
if ( !ok ) return;
|
||||||
await fetch( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } );
|
await fetch( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } );
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -95,20 +100,29 @@ class ProjectEditor extends HTMLElement {
|
||||||
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>
|
||||||
|
${ isOwner ? `
|
||||||
<form class="add-member-form">
|
<form class="add-member-form">
|
||||||
<select name="member_type">
|
<input name="email" type="email" placeholder="Email address" required>
|
||||||
<option value="user">User ID</option>
|
|
||||||
<option value="group">Group ID</option>
|
|
||||||
</select>
|
|
||||||
<input name="member_id" type="number" placeholder="ID" required>
|
|
||||||
<select name="role">
|
<select name="role">
|
||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
<option value="editor">Editor</option>
|
<option value="editor">Editor</option>
|
||||||
|
|
@ -116,6 +130,7 @@ class ProjectEditor extends HTMLElement {
|
||||||
</select>
|
</select>
|
||||||
<button type="submit">Add</button>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
` : '' }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
panel.querySelectorAll( '.btn-remove-member' ).forEach( btn => {
|
panel.querySelectorAll( '.btn-remove-member' ).forEach( btn => {
|
||||||
|
|
@ -126,7 +141,8 @@ class ProjectEditor extends HTMLElement {
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
(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`, {
|
||||||
|
|
@ -138,4 +154,5 @@ class ProjectEditor extends HTMLElement {
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define( 'project-editor', ProjectEditor );
|
customElements.define( 'project-editor', ProjectEditor );
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
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 ) =>
|
||||||
|
{
|
||||||
|
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 ) );
|
res.json( getFileTree( req.params.projectId ) );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
router.get('/:projectId/*', (req, res) => {
|
router.get( '/:projectId/*', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const access = checkAccess( req.params.projectId, req.user!, 'view' );
|
||||||
|
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
|
||||||
const filePath = ( req.params as Record<string, string> )[ 0 ];
|
const filePath = ( req.params as Record<string, string> )[ 0 ];
|
||||||
const content = readProjectFile( req.params.projectId, filePath );
|
const content = readProjectFile( req.params.projectId, filePath );
|
||||||
if ( content === null ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
if ( content === null ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||||
res.type( 'text/plain' ).send( content );
|
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 );
|
||||||
|
|
@ -24,7 +34,10 @@ router.post('/:projectId/create-file', (req, res) => {
|
||||||
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 );
|
||||||
|
|
@ -32,7 +45,10 @@ router.post('/:projectId/create-directory', (req, res) => {
|
||||||
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,7 +67,10 @@ 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 access = checkAccess( req.params.projectId, req.user!, 'edit' );
|
||||||
|
if ( !access.ok ) { res.status( access.status ).json( { error: access.error } ); return; }
|
||||||
const filePath = ( req.params as Record<string, string> )[ 0 ];
|
const filePath = ( req.params as Record<string, string> )[ 0 ];
|
||||||
if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }
|
if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }
|
||||||
const ok = writeProjectFile( req.params.projectId, filePath, req.body );
|
const ok = writeProjectFile( req.params.projectId, filePath, req.body );
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,24 @@ 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 } );
|
||||||
|
|
@ -17,24 +27,39 @@ router.post('/', (req, res) => {
|
||||||
res.json( project );
|
res.json( project );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete( '/:id', ( req, res ) =>
|
||||||
|
{
|
||||||
RJLog.log( "Deleting:", 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; }
|
||||||
projects.delete( req.params.id );
|
projects.delete( req.params.id );
|
||||||
res.json( { ok: true } );
|
res.json( { ok: true } );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
router.get('/:id/members', (req, res) => {
|
router.get( '/: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; }
|
||||||
|
const members = projectMembers.forProject( req.params.id );
|
||||||
|
if ( !canView( project, members, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
|
||||||
|
res.json( members );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
router.post('/:id/members', (req, res) => {
|
router.post( '/:id/members', ( 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; }
|
||||||
|
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.delete('/:id/members/:memberId', (req, res) => {
|
router.delete( '/:id/members/:memberId', ( 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; }
|
||||||
projectMembers.remove( req.params.memberId );
|
projectMembers.remove( req.params.memberId );
|
||||||
res.json( { ok: true } );
|
res.json( { ok: true } );
|
||||||
} );
|
} );
|
||||||
|
|
|
||||||
|
|
@ -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 <SERVICE_SECRET> 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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue