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 {
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;

View File

@ -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 );

View File

@ -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

View File

@ -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

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,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 );

View File

@ -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 } );
} );

View File

@ -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 &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-title>Local Git Repository Integration</task-title>
<task-content>