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 {
|
||||
width: 80px;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid #ccc;
|
||||
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 {
|
||||
padding: 0.4rem;
|
||||
border: 1px solid #ccc;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
||||
|
||||
interface CurrentUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -15,7 +20,11 @@ interface ProjectMember {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +48,7 @@ class ProjectEditor extends HTMLElement {
|
|||
<button class="btn-members" data-id="${ p.id }">Members</button>
|
||||
<button class="btn-delete" data-id="${ p.id }" data-name="${ p.name }">Delete</button>
|
||||
</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>
|
||||
` ).join( '' ) }
|
||||
</ul>
|
||||
|
|
@ -59,9 +68,7 @@ class ProjectEditor extends HTMLElement {
|
|||
} );
|
||||
|
||||
this.querySelectorAll( '.btn-delete' ).forEach( btn => {
|
||||
btn.addEventListener('click', async ( e ) => {
|
||||
|
||||
console.log( "Clicking:", e );
|
||||
btn.addEventListener( 'click', async () => {
|
||||
const el = btn as HTMLElement;
|
||||
const ok = await showConfirmDialog( {
|
||||
icon: '🗑',
|
||||
|
|
@ -71,8 +78,6 @@ class ProjectEditor extends HTMLElement {
|
|||
cancelLabel: 'Cancel',
|
||||
danger: true
|
||||
} );
|
||||
|
||||
console.log( "Result:", ok );
|
||||
if ( !ok ) return;
|
||||
await fetch( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } );
|
||||
this.render();
|
||||
|
|
@ -95,20 +100,29 @@ class ProjectEditor extends HTMLElement {
|
|||
const res = await fetch( `/api/projects/${ projectId }/members` );
|
||||
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 = `
|
||||
<ul class="member-list">
|
||||
${members.map(m => `
|
||||
<li>${m.member_type}: ${m.member_id} — ${m.role}
|
||||
<button class="btn-remove-member" data-pid="${projectId}" data-mid="${m.id}">Remove</button>
|
||||
</li>
|
||||
`).join('') || '<li class="empty">No members</li>'}
|
||||
<li class="member-owner">${ ownerLabel }</li>
|
||||
${ memberRows || '' }
|
||||
${ members.length === 0 ? '<li class="empty">No additional members</li>' : '' }
|
||||
</ul>
|
||||
${ isOwner ? `
|
||||
<form class="add-member-form">
|
||||
<select name="member_type">
|
||||
<option value="user">User ID</option>
|
||||
<option value="group">Group ID</option>
|
||||
</select>
|
||||
<input name="member_id" type="number" placeholder="ID" required>
|
||||
<input name="email" type="email" placeholder="Email address" required>
|
||||
<select name="role">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
|
|
@ -116,6 +130,7 @@ class ProjectEditor extends HTMLElement {
|
|||
</select>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
` : '' }
|
||||
`;
|
||||
|
||||
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();
|
||||
const data = Object.fromEntries( new FormData( e.target as HTMLFormElement ) );
|
||||
await fetch( `/api/projects/${ projectId }/members`, {
|
||||
|
|
@ -138,4 +154,5 @@ class ProjectEditor extends HTMLElement {
|
|||
} );
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define( 'project-editor', ProjectEditor );
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
inkscape:document-units="px"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.20890503"
|
||||
inkscape:cx="1969.7946"
|
||||
inkscape:cy="1179.962"
|
||||
inkscape:cx="2089.4662"
|
||||
inkscape:cy="1524.6162"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="-8"
|
||||
|
|
@ -35,6 +35,34 @@
|
|||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g17" /><defs
|
||||
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"
|
||||
inkscape:collect="never"><stop
|
||||
style="stop-color:#00bcf5;stop-opacity:1;"
|
||||
|
|
@ -157,7 +185,102 @@
|
|||
height="2.6460531"><feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
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:groupmode="layer"
|
||||
id="layer1"
|
||||
|
|
@ -274,4 +397,91 @@
|
|||
id="tspan14"
|
||||
x="1480.9904"
|
||||
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:document-units="px"
|
||||
showgrid="false"
|
||||
inkscape:zoom="9.4539622"
|
||||
inkscape:cx="-14.755718"
|
||||
inkscape:cy="24.910191"
|
||||
inkscape:zoom="3.3424804"
|
||||
inkscape:cx="-46.821516"
|
||||
inkscape:cy="45.923979"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg5" /><defs
|
||||
inkscape:current-layer="g26" /><defs
|
||||
id="defs2"><clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
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 { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { checkAccess } from '../projectAccess';
|
||||
|
||||
const router = Router();
|
||||
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 ) );
|
||||
} );
|
||||
|
||||
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 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 };
|
||||
if ( !filePath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
|
||||
const ok = createProjectFile( req.params.projectId, filePath );
|
||||
|
|
@ -24,7 +34,10 @@ router.post('/:projectId/create-file', (req, res) => {
|
|||
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 };
|
||||
if ( !dirPath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
|
||||
const ok = createProjectDirectory( req.params.projectId, dirPath );
|
||||
|
|
@ -32,7 +45,10 @@ router.post('/:projectId/create-directory', (req, res) => {
|
|||
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 };
|
||||
if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; }
|
||||
const ok = renameProjectEntry( req.params.projectId, oldPath, newName );
|
||||
|
|
@ -40,7 +56,10 @@ router.post( '/:projectId/rename', ( req, res ) => {
|
|||
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 };
|
||||
if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
|
||||
const ok = deleteProjectEntry( req.params.projectId, targetPath );
|
||||
|
|
@ -48,7 +67,10 @@ router.post( '/:projectId/delete', ( req, res ) => {
|
|||
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 ];
|
||||
if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }
|
||||
const ok = writeProjectFile( req.params.projectId, filePath, req.body );
|
||||
|
|
|
|||
|
|
@ -2,14 +2,24 @@ import { Router } from 'express';
|
|||
import { projects, projectMembers } from '../db';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { createProjectStorage } from '../storage';
|
||||
import { RJLog } from '../../library-ts/node/log/RJLog';
|
||||
import { isOwner, canView, getMemberRole } from '../projectAccess';
|
||||
|
||||
const router = Router();
|
||||
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 };
|
||||
if ( !name ) { res.status( 400 ).json( { error: 'Name required' } ); return; }
|
||||
const project = projects.create( { name, owner_id: req.user!.userId } );
|
||||
|
|
@ -17,24 +27,39 @@ router.post('/', (req, res) => {
|
|||
res.json( project );
|
||||
} );
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
|
||||
RJLog.log( "Deleting:", req.params.id );
|
||||
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 } );
|
||||
} );
|
||||
|
||||
router.get('/:id/members', (req, res) => {
|
||||
res.json(projectMembers.forProject(req.params.id));
|
||||
router.get( '/:id/members', ( req, res ) =>
|
||||
{
|
||||
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) => {
|
||||
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; }
|
||||
res.json(projectMembers.add({ project_id: req.params.id, member_type, member_id, role: role ?? 'viewer' }));
|
||||
router.post( '/:id/members', ( 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; }
|
||||
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 );
|
||||
res.json( { ok: true } );
|
||||
} );
|
||||
|
|
|
|||
|
|
@ -22,6 +22,32 @@
|
|||
<div class="lane">
|
||||
<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-title>Local Git Repository Integration</task-title>
|
||||
<task-content>
|
||||
|
|
|
|||
Loading…
Reference in New Issue