From 99f6f787ecc43269d23a4dc675fa7c1610ca5c0f Mon Sep 17 00:00:00 2001 From: Rokojori Date: Tue, 14 Jul 2026 15:05:04 +0200 Subject: [PATCH] Projects Udpate --- .../project-editor/project-editor.css | 25 +- .../project-editor/project-editor.ts | 159 +++++++------ source/rojos/roject.svg | 218 +++++++++++++++++- source/rojos/rojo-base.svg | 8 +- source/server/projectAccess.ts | 46 ++++ source/server/routes/files.ts | 84 ++++--- source/server/routes/projects.ts | 79 ++++--- workspace/boards/backlog.html | 26 +++ 8 files changed, 507 insertions(+), 138 deletions(-) create mode 100644 source/server/projectAccess.ts diff --git a/source/components/project-editor/project-editor.css b/source/components/project-editor/project-editor.css index e56db2d..5154217 100644 --- a/source/components/project-editor/project-editor.css +++ b/source/components/project-editor/project-editor.css @@ -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; diff --git a/source/components/project-editor/project-editor.ts b/source/components/project-editor/project-editor.ts index 69b83d8..cf78aae 100644 --- a/source/components/project-editor/project-editor.ts +++ b/source/components/project-editor/project-editor.ts @@ -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,12 +20,16 @@ interface ProjectMember { } class ProjectEditor extends HTMLElement { - connectedCallback(): void { + private currentUser: CurrentUser | null = null; + + async connectedCallback(): Promise { + const res = await fetch( '/api/auth/me' ); + if ( res.ok ) this.currentUser = await res.json() as CurrentUser; this.render(); } async render(): Promise { - const res = await fetch('/api/projects'); + const res = await fetch( '/api/projects' ); const projects = await res.json() as Project[]; this.innerHTML = ` @@ -31,111 +40,119 @@ class ProjectEditor extends HTMLElement {
    - ${projects.map(p => ` -
  • + ${ projects.map( p => ` +
  • - ${p.name} - Edit - - + ${ p.name } + Edit + +
    - +
  • - `).join('')} + ` ).join( '' ) }
`; - this.querySelector('.create-form')!.addEventListener('submit', async (e: Event) => { + this.querySelector( '.create-form' )!.addEventListener( 'submit', async ( e: Event ) => { e.preventDefault(); const form = e.target as HTMLFormElement; - const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim(); - const res = await fetch('/api/projects', { + const name = ( form.elements.namedItem( 'name' ) as HTMLInputElement ).value.trim(); + const res = await fetch( '/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) - }); - if (res.ok) this.render(); - }); + body: JSON.stringify( { name } ) + } ); + if ( res.ok ) this.render(); + } ); - this.querySelectorAll('.btn-delete').forEach(btn => { - btn.addEventListener('click', async ( e ) => { - - console.log( "Clicking:", e ); + this.querySelectorAll( '.btn-delete' ).forEach( btn => { + btn.addEventListener( 'click', async () => { const el = btn as HTMLElement; - const ok = await showConfirmDialog({ + const ok = await showConfirmDialog( { icon: '🗑', title: 'Delete Project', - message: `Delete "${el.dataset.name}"? This cannot be undone.`, + message: `Delete "${ el.dataset.name }"? This cannot be undone.`, confirmLabel: 'Delete', cancelLabel: 'Cancel', danger: true - }); - - console.log( "Result:", ok ); - if (!ok) return; - await fetch(`/api/projects/${el.dataset.id}`, { method: 'DELETE' }); + } ); + if ( !ok ) return; + await fetch( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } ); this.render(); - }); - }); + } ); + } ); - this.querySelectorAll('.btn-members').forEach(btn => { - btn.addEventListener('click', () => this.toggleMembers((btn as HTMLElement).dataset.id!)); - }); + this.querySelectorAll( '.btn-members' ).forEach( btn => { + btn.addEventListener( 'click', () => this.toggleMembers( ( btn as HTMLElement ).dataset.id! ) ); + } ); } - async toggleMembers(projectId: string): Promise { - const panel = this.querySelector(`.members-panel[data-project="${projectId}"]`) as HTMLElement; - if (panel.style.display !== 'none') { panel.style.display = 'none'; return; } + async toggleMembers( projectId: string ): Promise { + const panel = this.querySelector( `.members-panel[data-project="${ projectId }"]` ) as HTMLElement; + if ( panel.style.display !== 'none' ) { panel.style.display = 'none'; return; } panel.style.display = 'block'; - await this.loadMembers(projectId, panel); + await this.loadMembers( projectId, panel ); } - async loadMembers(projectId: string, panel: HTMLElement): Promise { - const res = await fetch(`/api/projects/${projectId}/members`); + async loadMembers( projectId: string, panel: HTMLElement ): Promise { + 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 } owner` + : `${ ownerId } owner`; + + const memberRows = members.map( m => ` +
  • + ${ m.member_id } + ${ m.role } + ${ isOwner ? `` : '' } +
  • + ` ).join( '' ); + panel.innerHTML = `
      - ${members.map(m => ` -
    • ${m.member_type}: ${m.member_id} — ${m.role} - -
    • - `).join('') || '
    • No members
    • '} +
    • ${ ownerLabel }
    • + ${ memberRows || '' } + ${ members.length === 0 ? '
    • No additional members
    • ' : '' }
    -
    - - - - -
    + ${ isOwner ? ` +
    + + + +
    + ` : '' } `; - panel.querySelectorAll('.btn-remove-member').forEach(btn => { - btn.addEventListener('click', async () => { + panel.querySelectorAll( '.btn-remove-member' ).forEach( btn => { + btn.addEventListener( 'click', async () => { const el = btn as HTMLElement; - await fetch(`/api/projects/${el.dataset.pid}/members/${el.dataset.mid}`, { method: 'DELETE' }); - await this.loadMembers(projectId, panel); - }); - }); + await fetch( `/api/projects/${ el.dataset.pid }/members/${ el.dataset.mid }`, { method: 'DELETE' } ); + await this.loadMembers( projectId, panel ); + } ); + } ); - (panel.querySelector('.add-member-form') as HTMLFormElement).addEventListener('submit', async (e: Event) => { + const addForm = panel.querySelector( '.add-member-form' ) as HTMLFormElement | null; + addForm?.addEventListener( 'submit', async ( e: Event ) => { e.preventDefault(); - const data = Object.fromEntries(new FormData(e.target as HTMLFormElement)); - await fetch(`/api/projects/${projectId}/members`, { + const data = Object.fromEntries( new FormData( e.target as HTMLFormElement ) ); + await fetch( `/api/projects/${ projectId }/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - await this.loadMembers(projectId, panel); - }); + body: JSON.stringify( data ) + } ); + await this.loadMembers( projectId, panel ); + } ); } } -customElements.define('project-editor', ProjectEditor); + +customElements.define( 'project-editor', ProjectEditor ); diff --git a/source/rojos/roject.svg b/source/rojos/roject.svg index 9d45528..cf15444 100644 --- a/source/rojos/roject.svg +++ b/source/rojos/roject.svg @@ -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" />COMPUTER! ZOOM IN! + 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!ROJECTROJECTROJECT diff --git a/source/rojos/rojo-base.svg b/source/rojos/rojo-base.svg index 2fd4ca2..c049d01 100644 --- a/source/rojos/rojo-base.svg +++ b/source/rojos/rojo-base.svg @@ -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" /> 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 }; +} diff --git a/source/server/routes/files.ts b/source/server/routes/files.ts index d6ed0db..76f4fe2 100644 --- a/source/server/routes/files.ts +++ b/source/server/routes/files.ts @@ -1,38 +1,54 @@ 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.use( requireAuth ); -router.get('/:projectId/tree', (req, res) => { - res.json(getFileTree(req.params.projectId)); -}); +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) => { - const filePath = (req.params as Record)[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.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 )[ 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); - if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; } - res.json({ ok: true }); -}); + if ( !filePath ) { res.status( 400 ).json( { error: 'path required' } ); return; } + const ok = createProjectFile( req.params.projectId, filePath ); + if ( !ok ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; } + 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); - if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; } - res.json({ ok: true }); -}); + if ( !dirPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } + const ok = createProjectDirectory( req.params.projectId, dirPath ); + if ( !ok ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; } + 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,12 +67,15 @@ router.post( '/:projectId/delete', ( req, res ) => { res.json( { ok: true } ); } ); -router.put('/:projectId/*', (req, res) => { - const filePath = (req.params as Record)[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); - if (!ok) { res.status(403).json({ error: 'Invalid path' }); return; } - res.json({ ok: true }); -}); +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 )[ 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 ); + if ( !ok ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; } + res.json( { ok: true } ); +} ); export default router; diff --git a/source/server/routes/projects.ts b/source/server/routes/projects.ts index 149e718..da00486 100644 --- a/source/server/routes/projects.ts +++ b/source/server/routes/projects.ts @@ -2,41 +2,66 @@ 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.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 }); - createProjectStorage(project.id); - res.json(project); -}); + if ( !name ) { res.status( 400 ).json( { error: 'Name required' } ); return; } + const project = projects.create( { name, owner_id: req.user!.userId } ); + createProjectStorage( project.id ); + res.json( project ); +} ); -router.delete('/:id', (req, res) => { +router.delete( '/:id', ( req, res ) => +{ + const project = projects.findById( req.params.id ); + if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } + if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } + projects.delete( req.params.id ); + res.json( { ok: true } ); +} ); - RJLog.log( "Deleting:", req.params.id ); - projects.delete(req.params.id); - res.json({ ok: true }); -}); +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.get('/:id/members', (req, res) => { - res.json(projectMembers.forProject(req.params.id)); -}); +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.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.delete('/:id/members/:memberId', (req, res) => { - projectMembers.remove(req.params.memberId); - res.json({ ok: true }); -}); +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 } ); +} ); export default router; diff --git a/workspace/boards/backlog.html b/workspace/boards/backlog.html index 29414d7..c5dd459 100644 --- a/workspace/boards/backlog.html +++ b/workspace/boards/backlog.html @@ -22,6 +22,32 @@
    MVP
    + + Add rokojori-auth lookup-email endpoint + + 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 + + + Local Git Repository Integration