diff --git a/MVP_SCOPE.md b/MVP_SCOPE.md index 30c3676..831232a 100644 --- a/MVP_SCOPE.md +++ b/MVP_SCOPE.md @@ -83,7 +83,7 @@ Approved on 2026-08-07 for Slice 7a: ## Release Boundary -The MVP is complete only when all required component, configuration, authentication, security, validation, persistence, local-user/RBAC, publishing, first-run administration, and deployment tasks in `ROADMAP.md` are complete; all six workflows above pass the Slice 6 release-validation process; and Slice 7a and Slice 7b acceptance pass. +The MVP is complete only when all required component, configuration, authentication, security, validation, persistence, local-user/RBAC, publishing, first-run administration, multi-page/scoped-variable, and deployment tasks in `ROADMAP.md` are complete; all six workflows above pass the Slice 6 release-validation process; and Slice 7a, Slice 7b, and Slice 7c acceptance pass. Post-MVP scope includes AI assistance, OIDC/SSO beyond the local authentication architecture, OAuth 2.0 for REST actions, IBM Cloud IAM, mTLS, advanced orchestration, and the future capabilities listed in `ROADMAP.md`. diff --git a/ROADMAP.md b/ROADMAP.md index 017681b..06bf399 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,6 +27,7 @@ The slice files remain the detailed implementation plans. When an older list con | [Slice 7](SLICE7.md) | MVP scope decision and requirement governance | Complete | None | | [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed | | [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Planned | Fresh-install setup, takeover prevention, recovery validation, and explicit sign-off | +| [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Planned | Page authoring, scoped runtime, deep links, security validation, and explicit sign-off | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | | [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain | @@ -263,9 +264,20 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution - [ ] Preserve the CLI bootstrap only as a documented emergency/recovery path. - [ ] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage. +### Slice 7c — Multi-page applications and variable scope + +- [ ] Add stable page slugs, ordering, navigation metadata, and a deterministic default page. +- [ ] Add explicit global/page variable scope with backward-compatible global defaults. +- [ ] Make components and their names page-local while retaining reusable project-level actions. +- [ ] Implement shared Preview/published navigation, default routes, and refreshable deep links. +- [ ] Preserve global and isolated page state across in-session navigation and reset both on reload. +- [ ] Implement first-entry `onLoad` and every-entry `onEnter` lifecycle semantics. +- [ ] Reject cross-page component/page-variable references and crafted cross-page published inputs. +- [ ] Complete compatibility, editor, runtime, publishing, security, browser, and manual acceptance coverage. + ## 8. Documentation, Packaging, and Release -Slice 8 release packaging now depends on completed Slice 7a authentication/RBAC/publishing and Slice 7b first-run setup behavior and documentation. Slice 9 OIDC/SSO remains post-v0.1.0 unless the product owner explicitly changes the boundary. +Slice 8 release packaging now depends on completed Slice 7a authentication/RBAC/publishing, Slice 7b first-run setup, and Slice 7c multi-page/scoped-variable behavior and documentation. Slice 9 OIDC/SSO remains post-v0.1.0 unless the product owner explicitly changes the boundary. ### Slice 8 — Documentation ownership and reconciliation diff --git a/backend/src/db/init.ts b/backend/src/db/init.ts index a1a5ac2..9c1a5db 100644 --- a/backend/src/db/init.ts +++ b/backend/src/db/init.ts @@ -43,6 +43,8 @@ export function initDatabase(): void { id TEXT PRIMARY KEY, username TEXT NOT NULL, normalized_username TEXT NOT NULL UNIQUE, + display_name TEXT, + email TEXT, password_hash TEXT, role TEXT NOT NULL CHECK (role IN ('admin', 'user')), enabled INTEGER NOT NULL DEFAULT 1, @@ -89,5 +91,9 @@ export function initDatabase(): void { if (!executionColumns.has('published_version')) db.exec('ALTER TABLE executions ADD COLUMN published_version INTEGER'); if (!executionColumns.has('user_id')) db.exec('ALTER TABLE executions ADD COLUMN user_id TEXT'); + const userColumns = new Set((db.pragma('table_info(users)') as Array<{name:string}>).map((column) => column.name)); + if (!userColumns.has('display_name')) db.exec('ALTER TABLE users ADD COLUMN display_name TEXT'); + if (!userColumns.has('email')) db.exec('ALTER TABLE users ADD COLUMN email TEXT'); + console.log('Database initialised'); } diff --git a/backend/src/db/sessions.ts b/backend/src/db/sessions.ts index ee527d3..a518c71 100644 --- a/backend/src/db/sessions.ts +++ b/backend/src/db/sessions.ts @@ -6,7 +6,7 @@ const sessionKey=process.env.CONDUCTOR_SESSION_KEY||(process.env.NODE_ENV==='pro if(!sessionKey)throw new Error('CONDUCTOR_SESSION_KEY is required when NODE_ENV=production.'); const hash = (value:string) => createHmac('sha256',sessionKey).update(value).digest('base64url'); export type SessionTokens = { sessionToken:string; csrfToken:string; expiresAt:string }; -export type SessionPrincipalRow = { user_id:string; username:string; role:'admin'|'user'; enabled:number; expires_at:string; revoked_at:string|null; csrf_hash:string }; +export type SessionPrincipalRow = { user_id:string; username:string; display_name:string|null; email:string|null; role:'admin'|'user'; enabled:number; expires_at:string; revoked_at:string|null; csrf_hash:string }; export function createSession(userId:string):SessionTokens { const sessionToken=randomBytes(32).toString('base64url'); const csrfToken=randomBytes(32).toString('base64url'); @@ -15,7 +15,7 @@ export function createSession(userId:string):SessionTokens { return {sessionToken,csrfToken,expiresAt}; } export function resolveSession(token:string):SessionPrincipalRow|undefined { - return db.prepare(`SELECT s.user_id,u.username,u.role,u.enabled,s.expires_at,s.revoked_at,s.csrf_hash FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id_hash=?`).get(hash(token)) as SessionPrincipalRow|undefined; + return db.prepare(`SELECT s.user_id,u.username,u.display_name,u.email,u.role,u.enabled,s.expires_at,s.revoked_at,s.csrf_hash FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id_hash=?`).get(hash(token)) as SessionPrincipalRow|undefined; } export function verifyCsrf(row:SessionPrincipalRow,token:string|undefined):boolean { return !!token && row.csrf_hash===hash(token); } export function revokeSession(token:string):void { db.prepare("UPDATE sessions SET revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id_hash=?").run(hash(token)); } diff --git a/backend/src/db/users.ts b/backend/src/db/users.ts index 38b9bf9..3aada92 100644 --- a/backend/src/db/users.ts +++ b/backend/src/db/users.ts @@ -2,25 +2,27 @@ import { randomUUID } from 'crypto'; import db from './database'; export type UserRole = 'admin' | 'user'; -export type UserRow = { id:string; username:string; normalized_username:string; password_hash:string|null; role:UserRole; enabled:number; provider:string; provider_subject:string|null; created_at:string; updated_at:string; last_login_at:string|null }; -export type UserPublic = { id:string; username:string; role:UserRole; enabled:boolean; provider:string; createdAt:string; updatedAt:string; lastLoginAt:string|null }; +export type UserRow = { id:string; username:string; normalized_username:string; display_name:string|null; email:string|null; password_hash:string|null; role:UserRole; enabled:number; provider:string; provider_subject:string|null; created_at:string; updated_at:string; last_login_at:string|null }; +export type UserPublic = { id:string; username:string; displayName:string|null; email:string|null; role:UserRole; enabled:boolean; provider:string; createdAt:string; updatedAt:string; lastLoginAt:string|null }; const normalize = (username:string) => username.trim().toLocaleLowerCase('en-US'); -const publicUser = (row:UserRow):UserPublic => ({id:row.id,username:row.username,role:row.role,enabled:row.enabled===1,provider:row.provider,createdAt:row.created_at,updatedAt:row.updated_at,lastLoginAt:row.last_login_at}); +const publicUser = (row:UserRow):UserPublic => ({id:row.id,username:row.username,displayName:row.display_name,email:row.email,role:row.role,enabled:row.enabled===1,provider:row.provider,createdAt:row.created_at,updatedAt:row.updated_at,lastLoginAt:row.last_login_at}); export function getUserRowById(id:string):UserRow|undefined { return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow|undefined; } export function findLocalUser(username:string):UserRow|undefined { return db.prepare('SELECT * FROM users WHERE normalized_username = ? AND provider = ?').get(normalize(username),'local') as UserRow|undefined; } export function listUsers():UserPublic[] { return (db.prepare('SELECT * FROM users ORDER BY username COLLATE NOCASE').all() as UserRow[]).map(publicUser); } export function getUser(id:string):UserPublic|undefined { const row=getUserRowById(id); return row?publicUser(row):undefined; } export function countAdmins():number { return (db.prepare("SELECT count(*) AS count FROM users WHERE role='admin' AND enabled=1").get() as {count:number}).count; } -export function createLocalUser(username:string,passwordHash:string,role:UserRole):UserPublic { +export function countUsers():number { return (db.prepare('SELECT count(*) AS count FROM users').get() as {count:number}).count; } +export function createLocalUser(username:string,passwordHash:string,role:UserRole,profile:{displayName?:string;email?:string}={}):UserPublic { const clean=username.trim(); if(!clean||clean.length>120) throw new Error('Username must contain 1 to 120 characters.'); - const id=randomUUID(); db.prepare('INSERT INTO users (id,username,normalized_username,password_hash,role,provider) VALUES (?,?,?,?,?,?)').run(id,clean,normalize(clean),passwordHash,role,'local'); + const id=randomUUID(); db.prepare('INSERT INTO users (id,username,normalized_username,display_name,email,password_hash,role,provider) VALUES (?,?,?,?,?,?,?,?)').run(id,clean,normalize(clean),profile.displayName?.trim()||null,profile.email?.trim()||null,passwordHash,role,'local'); return getUser(id)!; } -export function updateUser(id:string,input:{role?:UserRole;enabled?:boolean;passwordHash?:string}):UserPublic|undefined { +export const createInitialAdmin=db.transaction((username:string,passwordHash:string,profile:{displayName?:string;email?:string}={}):UserPublic=>{if(countUsers()!==0)throw new Error('SETUP_UNAVAILABLE');return createLocalUser(username,passwordHash,'admin',profile);}); +export function updateUser(id:string,input:{role?:UserRole;enabled?:boolean;passwordHash?:string;displayName?:string|null;email?:string|null}):UserPublic|undefined { const row=getUserRowById(id); if(!row)return undefined; - db.prepare("UPDATE users SET role=?, enabled=?, password_hash=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(input.role??row.role,input.enabled===undefined?row.enabled:(input.enabled?1:0),input.passwordHash??row.password_hash,id); + db.prepare("UPDATE users SET role=?, enabled=?, password_hash=?, display_name=?, email=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(input.role??row.role,input.enabled===undefined?row.enabled:(input.enabled?1:0),input.passwordHash??row.password_hash,input.displayName===undefined?row.display_name:(input.displayName?.trim()||null),input.email===undefined?row.email:(input.email?.trim()||null),id); return getUser(id); } export function markLogin(id:string):void { db.prepare("UPDATE users SET last_login_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(id); } diff --git a/backend/src/lib/authProvider.ts b/backend/src/lib/authProvider.ts index f83d7b9..c51f623 100644 --- a/backend/src/lib/authProvider.ts +++ b/backend/src/lib/authProvider.ts @@ -1,3 +1,3 @@ import type { UserRole } from '../db/users'; -export type AuthenticatedPrincipal = { userId:string; username:string; role:UserRole; provider:string }; +export type AuthenticatedPrincipal = { userId:string; username:string; displayName:string|null; email:string|null; role:UserRole; provider:string }; export interface AuthenticationProvider { authenticate(input:TInput):Promise; } diff --git a/backend/src/lib/localAuthProvider.ts b/backend/src/lib/localAuthProvider.ts index 19ebcd2..6bda660 100644 --- a/backend/src/lib/localAuthProvider.ts +++ b/backend/src/lib/localAuthProvider.ts @@ -5,6 +5,6 @@ import type { AuthenticationProvider, AuthenticatedPrincipal } from './authProvi export class LocalAuthenticationProvider implements AuthenticationProvider<{username:string;password:string}> { async authenticate(input:{username:string;password:string}):Promise { const user=findLocalUser(input.username); if(!user||user.enabled!==1||!await verifyPassword(input.password,user.password_hash))return null; - markLogin(user.id); return {userId:user.id,username:user.username,role:user.role,provider:'local'}; + markLogin(user.id); return {userId:user.id,username:user.username,displayName:user.display_name,email:user.email,role:user.role,provider:'local'}; } } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 6505f66..e4f0f9e 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -7,7 +7,7 @@ function cookie(req:Request,name:string):string|undefined { const raw=req.header export function optionalAuth(req:Request,_res:Response,next:NextFunction):void { const token=cookie(req,'conductor_session'); if(!token){next();return;} const row=resolveSession(token); if(!row||row.revoked_at||row.enabled!==1||Date.parse(row.expires_at)<=Date.now()){next();return;} - req.sessionToken=token;req.sessionRow=row;req.principal={userId:row.user_id,username:row.username,role:row.role,provider:'local'};next(); + req.sessionToken=token;req.sessionRow=row;req.principal={userId:row.user_id,username:row.username,displayName:row.display_name,email:row.email,role:row.role,provider:'local'};next(); } export function requireAuth(req:Request,res:Response,next:NextFunction):void { optionalAuth(req,res,()=>{if(!req.principal){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}next();}); } export function requireAdmin(req:Request,res:Response,next:NextFunction):void { requireAuth(req,res,()=>{if(req.principal?.role!=='admin'){res.status(403).json({code:'ADMIN_REQUIRED',error:'Administrator access is required.'});return;}next();}); } diff --git a/backend/src/routes/app.integration.test.ts b/backend/src/routes/app.integration.test.ts index 9a03e00..6148c8b 100644 --- a/backend/src/routes/app.integration.test.ts +++ b/backend/src/routes/app.integration.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { createServer, type IncomingMessage } from 'node:http'; import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -105,8 +105,6 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his processChild.kill('SIGTERM'); }); }; - const bootstrap = spawnSync(process.execPath, [path.resolve(__dirname, '../scripts/bootstrapAdmin.js')], { env: { ...process.env, CONDUCTOR_DATA_DIR: dataDir, CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME: 'integration-admin', CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD: 'integration-password-123' }, encoding: 'utf8' }); - assert.equal(bootstrap.status, 0, bootstrap.stderr); let child = spawnBackend(); t.after(async () => { @@ -124,11 +122,30 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his assert.equal(health.response.status, 200); assert.equal(health.body.status, 'ok'); - const login = await jsonRequest(backendUrl, '/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'integration-admin', password: 'integration-password-123' }) }); - assert.equal(login.response.status, 200); - csrfToken = login.body.csrfToken; - const setCookie = login.response.headers.get('set-cookie') ?? ''; + const setupStatus = await jsonRequest(backendUrl, '/api/auth/setup'); + assert.equal(setupStatus.body.setupRequired, true); + assert.ok(setupStatus.body.setupToken); + const setupSetCookie=setupStatus.response.headers.get('set-cookie')??''; + const setupCookie=`conductor_setup=${setupSetCookie.match(/conductor_setup=([^;]+)/)?.[1]}`; + const weakSetup=await fetch(`${backendUrl}/api/auth/setup`,{method:'POST',headers:{cookie:setupCookie,'x-setup-token':setupStatus.body.setupToken,'content-type':'application/json'},body:JSON.stringify({username:'integration-admin',password:'short'})}); + assert.equal(weakSetup.status,400); + const setupRequest=()=>fetch(`${backendUrl}/api/auth/setup`,{method:'POST',headers:{cookie:setupCookie,'x-setup-token':setupStatus.body.setupToken,'content-type':'application/json'},body:JSON.stringify({username:'integration-admin',password:'integration-password-123',displayName:'Integration Administrator',email:'admin@example.test'})}); + const setupResponses=await Promise.all([setupRequest(),setupRequest()]); + assert.deepEqual(setupResponses.map((response)=>response.status).sort(),[201,409]); + const setupSuccess=setupResponses.find((response)=>response.status===201)!; + const setupBody=await setupSuccess.json() as any; + assert.equal(setupBody.user.displayName,'Integration Administrator'); + assert.equal(setupBody.user.email,'admin@example.test'); + csrfToken = setupBody.csrfToken; + const setCookie = setupSuccess.headers.get('set-cookie') ?? ''; authCookie = [`conductor_session=${setCookie.match(/conductor_session=([^;]+)/)?.[1]}`, `conductor_csrf=${setCookie.match(/conductor_csrf=([^;]+)/)?.[1]}`].join('; '); + const completedSetup=await jsonRequest(backendUrl,'/api/auth/setup'); + assert.deepEqual(completedSetup.body,{setupRequired:false}); + const setupAgain=await fetch(`${backendUrl}/api/auth/setup`,{method:'POST',headers:{cookie:setupCookie,'x-setup-token':setupStatus.body.setupToken,'content-type':'application/json'},body:JSON.stringify({username:'takeover',password:'takeover-password-123'})}); + assert.equal(setupAgain.status,409); + for(let attempt=0;attempt<4;attempt+=1){assert.equal((await fetch(`${backendUrl}/api/auth/setup`,{method:'POST',headers:{cookie:setupCookie,'x-setup-token':setupStatus.body.setupToken,'content-type':'application/json'},body:'{}'})).status,409);} + const setupThrottled=await fetch(`${backendUrl}/api/auth/setup`,{method:'POST',headers:{cookie:setupCookie,'x-setup-token':setupStatus.body.setupToken,'content-type':'application/json'},body:'{}'}); + assert.equal(setupThrottled.status,429); const validDoc = projectDocument(); const validation = await jsonRequest(backendUrl, '/api/projects/validate', { @@ -190,9 +207,17 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his assert.equal(appUser.response.status, 201); const userLoginResponse = await fetch(`${backendUrl}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'integration-user', password: 'integration-user-password' }) }); assert.equal(userLoginResponse.status, 200); + const userLoginBody=await userLoginResponse.json() as any; const userSetCookie = userLoginResponse.headers.get('set-cookie') ?? ''; const userCookie = `conductor_session=${userSetCookie.match(/conductor_session=([^;]+)/)?.[1]}`; assert.equal((await fetch(`${backendUrl}/api/projects`, { headers: { cookie: userCookie } })).status, 403); + const wrongCurrent=await fetch(`${backendUrl}/api/auth/change-password`,{method:'POST',headers:{cookie:userCookie,'x-csrf-token':userLoginBody.csrfToken,'content-type':'application/json'},body:JSON.stringify({currentPassword:'wrong-password',newPassword:'changed-user-password'})}); + assert.equal(wrongCurrent.status,401); + const changedPassword=await fetch(`${backendUrl}/api/auth/change-password`,{method:'POST',headers:{cookie:userCookie,'x-csrf-token':userLoginBody.csrfToken,'content-type':'application/json'},body:JSON.stringify({currentPassword:'integration-user-password',newPassword:'changed-user-password'})}); + assert.equal(changedPassword.status,200); + assert.equal((await changedPassword.json() as any).changed,true); + assert.equal((await fetch(`${backendUrl}/api/auth/login`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:'integration-user',password:'integration-user-password'})})).status,401); + assert.equal((await fetch(`${backendUrl}/api/auth/login`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:'integration-user',password:'changed-user-password'})})).status,200); const publication = await jsonRequest(backendUrl, '/api/admin/published-apps', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sourceProjectId: projectId, slug: 'integration-app', displayName: 'Integration App', description: 'snapshot test', visibility: 'public' }) }); assert.equal(publication.response.status, 201, JSON.stringify(publication.body)); const savedAdminCookie = authCookie; authCookie = ''; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 26e26ab..aaf25f2 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,17 +1,76 @@ +import { randomBytes, timingSafeEqual } from 'crypto'; import { Router, type Request, type Response } from 'express'; import { LocalAuthenticationProvider } from '../lib/localAuthProvider'; import { createSession, revokeSession } from '../db/sessions'; import { clearCsrfCookie, clearSessionCookie, csrfCookie, optionalAuth, requireAuth, requireCsrf, sessionCookie } from '../middleware/auth'; +import { countUsers, createInitialAdmin, findLocalUser, updateUser } from '../db/users'; +import { hashPassword, validatePassword, verifyPassword } from '../lib/passwords'; +import { revokeUserSessions } from '../db/sessions'; -const router=Router(); const provider=new LocalAuthenticationProvider(); -const attempts=new Map(); const LIMIT=5, WINDOW=60_000; -function attemptKey(req:Request,username:string):string { return `${req.ip}:${username.trim().toLocaleLowerCase('en-US')}`; } -router.post('/login',async(req:Request,res:Response)=>{ - const username=typeof req.body?.username==='string'?req.body.username:''; const password=typeof req.body?.password==='string'?req.body.password:''; const key=attemptKey(req,username); const now=Date.now(); let bucket=attempts.get(key); - if(!bucket||bucket.reset<=now){bucket={count:0,reset:now+WINDOW};attempts.set(key,bucket);} if(bucket.count>=LIMIT){res.status(429).json({code:'LOGIN_THROTTLED',error:'Too many login attempts. Try again later.'});return;} - const principal=await provider.authenticate({username,password}); if(!principal){bucket.count+=1;res.status(401).json({code:'LOGIN_FAILED',error:'Invalid username or password.'});return;} - attempts.delete(key); const tokens=createSession(principal.userId); res.setHeader('Set-Cookie',[sessionCookie(tokens.sessionToken,tokens.expiresAt),csrfCookie(tokens.csrfToken,tokens.expiresAt)]); res.json({user:principal,csrfToken:tokens.csrfToken,expiresAt:tokens.expiresAt}); +const router=Router(); +const provider=new LocalAuthenticationProvider(); +const attempts=new Map(); +const setupAttempts=new Map(); +const LIMIT=5, WINDOW=60_000; +const clientKey=(req:Request)=>req.ip??req.socket.remoteAddress??'unknown'; +function attemptKey(req:Request,username:string):string{return `${clientKey(req)}:${username.trim().toLocaleLowerCase('en-US')}`;} +function setupCookie(token:string):string{return `conductor_setup=${encodeURIComponent(token)}; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age=600${process.env.NODE_ENV==='production'?'; Secure':''}`;} +function clearSetupCookie():string{return `conductor_setup=; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age=0${process.env.NODE_ENV==='production'?'; Secure':''}`;} +function cookie(req:Request,name:string):string|undefined{for(const part of (req.headers.cookie??'').split(';')){const[key,...rest]=part.trim().split('=');if(key===name)return decodeURIComponent(rest.join('='));}return undefined;} +function equalToken(a:string|undefined,b:string|undefined):boolean{if(!a||!b)return false;const left=Buffer.from(a),right=Buffer.from(b);return left.length===right.length&&timingSafeEqual(left,right);} + +router.get('/setup',(_req,res)=>{ + if(countUsers()!==0){res.setHeader('Cache-Control','no-store');res.json({setupRequired:false});return;} + const setupToken=randomBytes(32).toString('base64url'); + res.setHeader('Set-Cookie',setupCookie(setupToken)); + res.setHeader('Cache-Control','no-store'); + res.json({setupRequired:true,setupToken}); }); -router.get('/me',optionalAuth,(req,res)=>{ if(!req.principal){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;} res.json({user:req.principal}); }); + +router.post('/setup',async(req:Request,res:Response)=>{ + const key=clientKey(req),now=Date.now(); + let bucket=setupAttempts.get(key); + if(!bucket||bucket.reset<=now){bucket={count:0,reset:now+WINDOW};setupAttempts.set(key,bucket);} + if(bucket.count>=LIMIT){res.status(429).json({code:'SETUP_THROTTLED',error:'Too many setup attempts. Try again later.'});return;} + bucket.count+=1; + if(countUsers()!==0){res.status(409).json({code:'SETUP_UNAVAILABLE',error:'Initial setup has already been completed.'});return;} + if(!equalToken(cookie(req,'conductor_setup'),req.header('x-setup-token'))){res.status(403).json({code:'SETUP_TOKEN_INVALID',error:'The setup security token is missing or invalid.'});return;} + const username=typeof req.body?.username==='string'?req.body.username:''; + const password=typeof req.body?.password==='string'?req.body.password:''; + const displayName=typeof req.body?.displayName==='string'?req.body.displayName:''; + const email=typeof req.body?.email==='string'?req.body.email:''; + const issue=validatePassword(password); + if(!username.trim()||username.trim().length>120||displayName.length>160||email.length>254||(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))||issue){res.status(400).json({code:'SETUP_INPUT_INVALID',error:!username.trim()?'Username is required.':issue??'Profile information is invalid.'});return;} + try{ + const user=createInitialAdmin(username,await hashPassword(password),{displayName,email}); + const tokens=createSession(user.id);setupAttempts.delete(key); + res.setHeader('Set-Cookie',[clearSetupCookie(),sessionCookie(tokens.sessionToken,tokens.expiresAt),csrfCookie(tokens.csrfToken,tokens.expiresAt)]); + res.status(201).json({user:{userId:user.id,username:user.username,displayName:user.displayName,email:user.email,role:user.role,provider:user.provider},csrfToken:tokens.csrfToken,expiresAt:tokens.expiresAt}); + }catch(error){ + if(error instanceof Error&&error.message==='SETUP_UNAVAILABLE'){res.status(409).json({code:'SETUP_UNAVAILABLE',error:'Initial setup has already been completed.'});return;} + res.status(409).json({code:'SETUP_FAILED',error:'Initial administrator could not be created.'}); + } +}); + +router.post('/login',async(req:Request,res:Response)=>{ + const username=typeof req.body?.username==='string'?req.body.username:'';const password=typeof req.body?.password==='string'?req.body.password:'';const key=attemptKey(req,username);const now=Date.now();let bucket=attempts.get(key); + if(!bucket||bucket.reset<=now){bucket={count:0,reset:now+WINDOW};attempts.set(key,bucket);}if(bucket.count>=LIMIT){res.status(429).json({code:'LOGIN_THROTTLED',error:'Too many login attempts. Try again later.'});return;} + const principal=await provider.authenticate({username,password});if(!principal){bucket.count+=1;res.status(401).json({code:'LOGIN_FAILED',error:'Invalid username or password.'});return;} + attempts.delete(key);const tokens=createSession(principal.userId);res.setHeader('Set-Cookie',[sessionCookie(tokens.sessionToken,tokens.expiresAt),csrfCookie(tokens.csrfToken,tokens.expiresAt)]);res.json({user:principal,csrfToken:tokens.csrfToken,expiresAt:tokens.expiresAt}); +}); +router.post('/change-password',requireAuth,requireCsrf,async(req:Request,res:Response)=>{ + const currentPassword=typeof req.body?.currentPassword==='string'?req.body.currentPassword:''; + const newPassword=typeof req.body?.newPassword==='string'?req.body.newPassword:''; + const issue=validatePassword(newPassword); + if(issue){res.status(400).json({code:'PASSWORD_INVALID',error:issue});return;} + const user=req.principal?.provider==='local'?findLocalUser(req.principal.username):undefined; + if(!user||!user.password_hash||!await verifyPassword(currentPassword,user.password_hash)){res.status(401).json({code:'CURRENT_PASSWORD_INVALID',error:'The current password is incorrect.'});return;} + if(await verifyPassword(newPassword,user.password_hash)){res.status(400).json({code:'PASSWORD_UNCHANGED',error:'The new password must be different from the current password.'});return;} + updateUser(user.id,{passwordHash:await hashPassword(newPassword)});revokeUserSessions(user.id); + const tokens=createSession(user.id); + res.setHeader('Set-Cookie',[sessionCookie(tokens.sessionToken,tokens.expiresAt),csrfCookie(tokens.csrfToken,tokens.expiresAt)]); + res.json({changed:true,csrfToken:tokens.csrfToken,expiresAt:tokens.expiresAt}); +}); +router.get('/me',optionalAuth,(req,res)=>{if(!req.principal){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}res.json({user:req.principal});}); router.post('/logout',requireAuth,requireCsrf,(req,res)=>{if(req.sessionToken)revokeSession(req.sessionToken);res.setHeader('Set-Cookie',[clearSessionCookie(),clearCsrfCookie()]);res.status(204).send();}); export default router; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 424b5e1..cc7a3ce 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -4,9 +4,10 @@ import { revokeUserSessions } from '../db/sessions'; import { hashPassword, validatePassword } from '../lib/passwords'; const router=Router(); const role=(value:unknown):value is UserRole=>value==='admin'||value==='user'; +const profileInvalid=(displayName:unknown,email:unknown)=>typeof displayName==='string'&&displayName.length>160||typeof email==='string'&&(email.length>254||!!email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)); router.get('/',(_req,res)=>res.json(listUsers())); -router.post('/',async(req,res)=>{const {username,password}=req.body??{};const nextRole=role(req.body?.role)?req.body.role:'user';if(typeof username!=='string'||!username.trim()){res.status(400).json({code:'USERNAME_REQUIRED',error:'Username is required.'});return;}if(typeof password!=='string'||validatePassword(password)){res.status(400).json({code:'PASSWORD_INVALID',error:typeof password==='string'?validatePassword(password):'Password is required.'});return;}try{res.status(201).json(createLocalUser(username,await hashPassword(password),nextRole));}catch(error){res.status(409).json({code:'USER_CREATE_FAILED',error:error instanceof Error&&error.message.includes('UNIQUE')?'Username already exists.':'User could not be created.'});}}); -router.put('/:id',(req,res)=>{const existing=getUser(req.params.id);if(!existing){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}const nextRole=role(req.body?.role)?req.body.role:undefined;const enabled=typeof req.body?.enabled==='boolean'?req.body.enabled:undefined;if(existing.role==='admin'&&existing.enabled&&(nextRole==='user'||enabled===false)&&countAdmins()<=1){res.status(409).json({code:'LAST_ADMIN_REQUIRED',error:'At least one enabled administrator is required.'});return;}const changed=updateUser(existing.id,{role:nextRole,enabled});if(nextRole||enabled===false)revokeUserSessions(existing.id);res.json(changed);}); +router.post('/',async(req,res)=>{const {username,password}=req.body??{};const nextRole=role(req.body?.role)?req.body.role:'user';if(typeof username!=='string'||!username.trim()){res.status(400).json({code:'USERNAME_REQUIRED',error:'Username is required.'});return;}if(typeof password!=='string'||validatePassword(password)){res.status(400).json({code:'PASSWORD_INVALID',error:typeof password==='string'?validatePassword(password):'Password is required.'});return;}if(profileInvalid(req.body?.displayName,req.body?.email)){res.status(400).json({code:'PROFILE_INVALID',error:'Display name or contact email is invalid.'});return;}try{res.status(201).json(createLocalUser(username,await hashPassword(password),nextRole,{displayName:typeof req.body?.displayName==='string'?req.body.displayName:'',email:typeof req.body?.email==='string'?req.body.email:''}));}catch(error){res.status(409).json({code:'USER_CREATE_FAILED',error:error instanceof Error&&error.message.includes('UNIQUE')?'Username already exists.':'User could not be created.'});}}); +router.put('/:id',(req,res)=>{const existing=getUser(req.params.id);if(!existing){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}if(profileInvalid(req.body?.displayName,req.body?.email)){res.status(400).json({code:'PROFILE_INVALID',error:'Display name or contact email is invalid.'});return;}const nextRole=role(req.body?.role)?req.body.role:undefined;const enabled=typeof req.body?.enabled==='boolean'?req.body.enabled:undefined;if(existing.role==='admin'&&existing.enabled&&(nextRole==='user'||enabled===false)&&countAdmins()<=1){res.status(409).json({code:'LAST_ADMIN_REQUIRED',error:'At least one enabled administrator is required.'});return;}const changed=updateUser(existing.id,{role:nextRole,enabled,displayName:typeof req.body?.displayName==='string'?req.body.displayName:undefined,email:typeof req.body?.email==='string'?req.body.email:undefined});if(nextRole||enabled===false)revokeUserSessions(existing.id);res.json(changed);}); router.post('/:id/reset-password',async(req,res)=>{const password=req.body?.password;if(typeof password!=='string'||validatePassword(password)){res.status(400).json({code:'PASSWORD_INVALID',error:typeof password==='string'?validatePassword(password):'Password is required.'});return;}if(!getUser(req.params.id)){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}const changed=updateUser(req.params.id,{passwordHash:await hashPassword(password)});revokeUserSessions(req.params.id);res.json(changed);}); router.post('/:id/revoke-sessions',(req,res)=>{if(!getUser(req.params.id)){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}revokeUserSessions(req.params.id);res.status(204).send();}); export default router; diff --git a/backend/src/scripts/bootstrapAdmin.ts b/backend/src/scripts/bootstrapAdmin.ts index c91e046..86bf4eb 100644 --- a/backend/src/scripts/bootstrapAdmin.ts +++ b/backend/src/scripts/bootstrapAdmin.ts @@ -1,4 +1,24 @@ import { initDatabase } from '../db/init'; -import { countAdmins, createLocalUser } from '../db/users'; +import { countUsers, createLocalUser, findLocalUser, updateUser } from '../db/users'; +import { revokeUserSessions } from '../db/sessions'; import { hashPassword, validatePassword } from '../lib/passwords'; -async function main(){initDatabase();const username=process.env.CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME?.trim();const password=process.env.CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD;if(!username||!password)throw new Error('Set CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME and CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD.');const issue=validatePassword(password);if(issue)throw new Error(issue);if(countAdmins()>0){console.log('An enabled administrator already exists; no changes made.');return;}createLocalUser(username,await hashPassword(password),'admin');console.log(`Bootstrap administrator "${username}" created. Rotate the supplied password after first login.`);}void main().catch((error)=>{console.error(error instanceof Error?error.message:String(error));process.exitCode=1;}); + +async function main(){ + initDatabase(); + const recoveryUsername=process.env.CONDUCTOR_RECOVERY_ADMIN_USERNAME?.trim(); + const recoveryPassword=process.env.CONDUCTOR_RECOVERY_ADMIN_PASSWORD; + if(recoveryUsername||recoveryPassword){ + if(process.env.CONDUCTOR_ALLOW_ADMIN_RECOVERY!=='I_UNDERSTAND')throw new Error('Set CONDUCTOR_ALLOW_ADMIN_RECOVERY=I_UNDERSTAND to authorize operator recovery.'); + if(!recoveryUsername||!recoveryPassword)throw new Error('Set both recovery username and password.'); + const issue=validatePassword(recoveryPassword);if(issue)throw new Error(issue); + const user=findLocalUser(recoveryUsername);if(!user||user.role!=='admin')throw new Error('The requested local administrator does not exist.'); + updateUser(user.id,{passwordHash:await hashPassword(recoveryPassword),enabled:true});revokeUserSessions(user.id); + console.log(`Administrator "${user.username}" recovered and existing sessions revoked.`);return; + } + const username=process.env.CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME?.trim();const password=process.env.CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD; + if(!username||!password)throw new Error('Browser first-run setup is recommended. For empty-installation CLI bootstrap, set CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME and CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD.'); + const issue=validatePassword(password);if(issue)throw new Error(issue); + if(countUsers()>0){console.log('The installation already contains users; no changes made. Use explicit recovery for an existing administrator.');return;} + createLocalUser(username,await hashPassword(password),'admin');console.log(`Bootstrap administrator "${username}" created.`); +} +void main().catch((error)=>{console.error(error instanceof Error?error.message:String(error));process.exitCode=1;}); diff --git a/docs/FIRST_RUN_SETUP.md b/docs/FIRST_RUN_SETUP.md new file mode 100644 index 0000000..b9f9986 --- /dev/null +++ b/docs/FIRST_RUN_SETUP.md @@ -0,0 +1,34 @@ +# First-Run Administration + +On an empty installation, open Conductor in a browser. The setup screen creates the first administrator and signs that administrator in. The setup endpoint closes permanently as soon as any user exists; it is not a registration endpoint. + +The setup form requires a unique username and a password of at least 12 characters. Display name and contact email are optional. Passwords are masked, confirmed in the browser, hashed before storage, and never returned or logged. + +Initial creation is protected by a short-lived same-site setup cookie/header token, request throttling, and an atomic empty-database check. Concurrent setup attempts can create at most one administrator. + +## Empty-installation CLI fallback + +Browser setup is the normal path. Operators may bootstrap an empty installation non-interactively when browser access is unavailable: + +```bash +docker compose run --rm \ + -e CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME='operator' \ + -e CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD='a-unique-password-of-12-or-more-characters' \ + backend npm run bootstrap-admin +``` + +The command refuses weak credentials and makes no change when an enabled administrator already exists. + +## Explicit administrator recovery + +If every administrator is inaccessible, an operator with access to the persisted Conductor data volume may reset one existing local administrator. Stop normal administrative work during recovery and provide secrets only in the private terminal environment: + +```bash +docker compose run --rm \ + -e CONDUCTOR_ALLOW_ADMIN_RECOVERY='I_UNDERSTAND' \ + -e CONDUCTOR_RECOVERY_ADMIN_USERNAME='operator' \ + -e CONDUCTOR_RECOVERY_ADMIN_PASSWORD='a-new-unique-password-of-12-or-more-characters' \ + backend npm run bootstrap-admin +``` + +Recovery only targets an existing local administrator, re-enables that account, replaces its password hash, and revokes all of its sessions. It cannot create a new administrator or elevate a normal user. Remove sensitive shell history according to local operator policy and rotate the recovered password after access is restored. diff --git a/docs/MVP_TRACEABILITY.md b/docs/MVP_TRACEABILITY.md index cdb867c..d4fd66e 100644 --- a/docs/MVP_TRACEABILITY.md +++ b/docs/MVP_TRACEABILITY.md @@ -25,6 +25,7 @@ This matrix maps every release-critical requirement area in `docs/REQUIREMENTS.m | R15 | Local authentication, secure sessions, global admin/user RBAC, admin-only authoring, and user lifecycle management | Slice 7a | Backend integration security matrix, frontend regression suite, and accepted manual admin/user workflows | Accepted | | R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted | | R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security tests, fresh-install browser E2E, recovery verification, and manual acceptance | Planned | +| R18 | Authored and published applications support multiple deep-linked pages with page-local components and explicit global/page runtime-variable scope | Slice 7c | Schema/editor/runtime/publishing tests, multi-page browser E2E, security validation, and manual acceptance | Planned | ## Approved acceptance workflows diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index edb8a62..1b6d281 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import AppCatalog from './components/Published/AppCatalog'; import PublishedApp from './components/Published/PublishedApp'; import UserManagement from './components/Admin/UserManagement'; import Publishing from './components/Admin/Publishing'; +import FirstRunSetup from './components/Auth/FirstRunSetup'; // Placeholder panels — replaced with real implementations in later steps function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement { @@ -51,10 +52,11 @@ function AppContent(): React.ReactElement { // ProjectProvider wraps the entire app so all editor views share one context. function AuthenticatedApp(): React.ReactElement { - const { user, loading } = useAuth(); + const { user, loading, setupRequired } = useAuth(); const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)\/?$/); if (publishedMatch) return ; if (loading) return

Loading…

; + if (setupRequired) return ; if (!user) return ; if (user.role === 'user') return ; return ( diff --git a/frontend/src/components/Admin/UserManagement.tsx b/frontend/src/components/Admin/UserManagement.tsx index b9dbd0b..c764da8 100644 --- a/frontend/src/components/Admin/UserManagement.tsx +++ b/frontend/src/components/Admin/UserManagement.tsx @@ -1,20 +1,18 @@ import React from 'react'; import { apiFetch } from '../../api/apiClient'; - -type User={id:string;username:string;role:'admin'|'user';enabled:boolean;lastLoginAt?:string}; +type User={id:string;username:string;displayName:string|null;email:string|null;role:'admin'|'user';enabled:boolean;lastLoginAt?:string}; async function request(url:string,init?:RequestInit):Promise{const r=await apiFetch(url,init);if(!r.ok){const b=await r.json().catch(()=>({})) as {error?:string};throw new Error(b.error??`HTTP ${r.status}`)}return r.status===204?undefined as T:r.json() as Promise} -export default function UserManagement():React.ReactElement { - const[users,setUsers]=React.useState([]),[error,setError]=React.useState(''),[username,setUsername]=React.useState(''),[password,setPassword]=React.useState(''),[confirm,setConfirm]=React.useState(''),[role,setRole]=React.useState<'admin'|'user'>('user'),[resetUser,setResetUser]=React.useState(null),[resetPassword,setResetPassword]=React.useState(''),[resetConfirm,setResetConfirm]=React.useState(''); - const load=()=>request('/api/admin/users').then(setUsers).catch(e=>setError(String(e))); - React.useEffect(()=>{void load();},[]); - const create=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(password!==confirm){setError('Passwords do not match.');return;}try{await request('/api/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password,role})});setUsername('');setPassword('');setConfirm('');void load()}catch(x){setError(x instanceof Error?x.message:String(x))}}; - const update=async(user:User,changes:Partial)=>{try{await request(`/api/admin/users/${user.id}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(changes)});void load()}catch(x){setError(x instanceof Error?x.message:String(x))}}; - const submitReset=async(e:React.FormEvent)=>{e.preventDefault();if(!resetUser)return;setError('');if(resetPassword!==resetConfirm){setError('Passwords do not match.');return;}try{await request(`/api/admin/users/${resetUser.id}/reset-password`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:resetPassword})});setResetUser(null);setResetPassword('');setResetConfirm('')}catch(x){setError(x instanceof Error?x.message:String(x))}}; - const revoke=(user:User)=>request(`/api/admin/users/${user.id}/revoke-sessions`,{method:'POST'}).catch(e=>setError(String(e))); - return

User management

Create local accounts, assign roles, disable access, and revoke active sessions.

{error&&

{error}

} -
setUsername(e.target.value)}/>setPassword(e.target.value)}/>setConfirm(e.target.value)}/>
-
{users.map(user=>
{user.username}
)}
- {resetUser&&

Reset password for {resetUser.username}

Enter the new password twice. It must contain at least 12 characters.

} -
; +export default function UserManagement():React.ReactElement{ + const[users,setUsers]=React.useState([]),[error,setError]=React.useState(''),[username,setUsername]=React.useState(''),[displayName,setDisplayName]=React.useState(''),[email,setEmail]=React.useState(''),[password,setPassword]=React.useState(''),[confirm,setConfirm]=React.useState(''),[role,setRole]=React.useState<'admin'|'user'>('user'),[resetUser,setResetUser]=React.useState(null),[resetPassword,setResetPassword]=React.useState(''),[resetConfirm,setResetConfirm]=React.useState(''); + const load=React.useCallback(()=>request('/api/admin/users').then(setUsers).catch(e=>setError(String(e))),[]);React.useEffect(()=>{void load()},[load]); + const create=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(password!==confirm){setError('Passwords do not match.');return;}try{await request('/api/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,displayName,email,password,role})});setUsername('');setDisplayName('');setEmail('');setPassword('');setConfirm('');void load()}catch(x){setError(x instanceof Error?x.message:String(x))}}; + const update=async(user:User,changes:Partial)=>{try{await request(`/api/admin/users/${user.id}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(changes)});void load()}catch(x){setError(x instanceof Error?x.message:String(x))}}; + const submitReset=async(e:React.FormEvent)=>{e.preventDefault();if(!resetUser)return;setError('');if(resetPassword!==resetConfirm){setError('Passwords do not match.');return;}try{await request(`/api/admin/users/${resetUser.id}/reset-password`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:resetPassword})});setResetUser(null);setResetPassword('');setResetConfirm('')}catch(x){setError(x instanceof Error?x.message:String(x))}}; + const revoke=(user:User)=>request(`/api/admin/users/${user.id}/revoke-sessions`,{method:'POST'}).catch(e=>setError(String(e))); + return

User management

Usernames identify accounts. Display name and contact address are optional profile information.

{error&&

{error}

} +
setUsername(e.target.value)}/>setDisplayName(e.target.value)}/>setEmail(e.target.value)}/>setPassword(e.target.value)}/>setConfirm(e.target.value)}/>
+
{users.map(user=>
{user.username}
)}
+ {resetUser&&

Reset password for {resetUser.username}

Enter the new password twice. It must contain at least 12 characters.

} +
; } diff --git a/frontend/src/components/Auth/ChangePassword.test.tsx b/frontend/src/components/Auth/ChangePassword.test.tsx new file mode 100644 index 0000000..5783b03 --- /dev/null +++ b/frontend/src/components/Auth/ChangePassword.test.tsx @@ -0,0 +1,4 @@ +import React,{act}from'react';import{createRoot,type Root}from'react-dom/client';import ChangePassword from'./ChangePassword';import{apiFetch}from'../../api/apiClient'; +jest.mock('../../api/apiClient',()=>({apiFetch:jest.fn()}));const mockedFetch=apiFetch as jest.Mock;(globalThis as typeof globalThis&{IS_REACT_ACT_ENVIRONMENT:boolean}).IS_REACT_ACT_ENVIRONMENT=true; +function setInput(input:HTMLInputElement,value:string){const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set!;act(()=>{setter.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}))})} +describe('ChangePassword',()=>{let container:HTMLDivElement,root:Root;beforeEach(()=>{container=document.createElement('div');document.body.appendChild(container);root=createRoot(container)});afterEach(()=>{act(()=>root.unmount());container.remove();jest.resetAllMocks()});test('uses three masked fields and submits current plus confirmed new password',async()=>{mockedFetch.mockResolvedValue({ok:true,json:async()=>({changed:true})});await act(async()=>root.render());const inputs=container.querySelectorAll('input');expect([...inputs].every(input=>input.type==='password')).toBe(true);setInput(inputs[0],'current-password');setInput(inputs[1],'new-password-123');setInput(inputs[2],'new-password-123');await act(async()=>container.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})));expect(mockedFetch).toHaveBeenCalledWith('/api/auth/change-password',expect.objectContaining({method:'POST',body:JSON.stringify({currentPassword:'current-password',newPassword:'new-password-123'})}));expect(container.textContent).toContain('You remain signed in on this device');expect(container.textContent).toContain('all other sessions were signed out');});test('rejects mismatched confirmation without making a request',async()=>{await act(async()=>root.render());const inputs=container.querySelectorAll('input');setInput(inputs[0],'current-password');setInput(inputs[1],'new-password-123');setInput(inputs[2],'different-password');await act(async()=>container.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})));expect(mockedFetch).not.toHaveBeenCalled();expect(container.textContent).toContain('New passwords do not match.');});}); diff --git a/frontend/src/components/Auth/ChangePassword.tsx b/frontend/src/components/Auth/ChangePassword.tsx new file mode 100644 index 0000000..6346694 --- /dev/null +++ b/frontend/src/components/Auth/ChangePassword.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { apiFetch } from '../../api/apiClient'; + +const field:React.CSSProperties={display:'block',width:'100%',boxSizing:'border-box',padding:8,margin:'5px 0 12px'}; +export default function ChangePassword():React.ReactElement{ + const[currentPassword,setCurrentPassword]=React.useState(''),[newPassword,setNewPassword]=React.useState(''),[confirm,setConfirm]=React.useState(''),[error,setError]=React.useState(''),[success,setSuccess]=React.useState(''),[busy,setBusy]=React.useState(false); + const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');setSuccess('');if(newPassword!==confirm){setError('New passwords do not match.');return;}setBusy(true);try{const response=await apiFetch('/api/auth/change-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPassword,newPassword})});if(!response.ok){const body=await response.json().catch(()=>({})) as {error?:string};throw new Error(body.error??'Password could not be changed.');}setCurrentPassword('');setNewPassword('');setConfirm('');setSuccess('Password changed. You remain signed in on this device; all other sessions were signed out.');}catch(x){setError(x instanceof Error?x.message:'Password could not be changed.');}finally{setBusy(false)}}; + return
Change my password

Use at least 12 characters. Changing it signs out your other sessions.

{error&&

{error}

}{success&&

{success}

}
; +} diff --git a/frontend/src/components/Auth/FirstRunSetup.test.tsx b/frontend/src/components/Auth/FirstRunSetup.test.tsx new file mode 100644 index 0000000..7aac245 --- /dev/null +++ b/frontend/src/components/Auth/FirstRunSetup.test.tsx @@ -0,0 +1,18 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import FirstRunSetup from './FirstRunSetup'; +import { useAuth } from '../../context/AuthContext'; + +jest.mock('../../context/AuthContext',()=>({useAuth:jest.fn()})); +const mockedUseAuth=useAuth as jest.Mock; +(globalThis as typeof globalThis&{IS_REACT_ACT_ENVIRONMENT:boolean}).IS_REACT_ACT_ENVIRONMENT=true; +function setInput(input:HTMLInputElement,value:string):void{const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set!;act(()=>{setter.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));});} + +describe('FirstRunSetup',()=>{ + let container:HTMLDivElement,root:Root,completeSetup:jest.Mock; + beforeEach(()=>{container=document.createElement('div');document.body.appendChild(container);root=createRoot(container);completeSetup=jest.fn().mockResolvedValue(undefined);mockedUseAuth.mockReturnValue({completeSetup});}); + afterEach(()=>{act(()=>root.unmount());container.remove();jest.resetAllMocks();}); + test('uses masked confirmation fields and submits optional profile information once',async()=>{await act(async()=>root.render());const inputs=container.querySelectorAll('input');setInput(inputs[0],'admin');setInput(inputs[1],'Ada Admin');setInput(inputs[2],'ada@example.test');setInput(inputs[3],'a-secure-password');setInput(inputs[4],'a-secure-password');expect(inputs[3].type).toBe('password');expect(inputs[4].type).toBe('password');await act(async()=>{container.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});expect(completeSetup).toHaveBeenCalledTimes(1);expect(completeSetup).toHaveBeenCalledWith({username:'admin',displayName:'Ada Admin',email:'ada@example.test',password:'a-secure-password'});}); + test('rejects mismatched confirmation without sending credentials',async()=>{await act(async()=>root.render());const inputs=container.querySelectorAll('input');setInput(inputs[0],'admin');setInput(inputs[3],'a-secure-password');setInput(inputs[4],'a-different-password');await act(async()=>{container.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});expect(completeSetup).not.toHaveBeenCalled();expect(container.textContent).toContain('Passwords do not match.');}); + test('prevents duplicate submission while setup is in flight',async()=>{let finish!:()=>void;completeSetup.mockReturnValue(new Promise(resolve=>{finish=resolve;}));await act(async()=>root.render());const inputs=container.querySelectorAll('input');setInput(inputs[0],'admin');setInput(inputs[3],'a-secure-password');setInput(inputs[4],'a-secure-password');act(()=>{container.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});expect(container.querySelector('button')!.disabled).toBe(true);expect(completeSetup).toHaveBeenCalledTimes(1);await act(async()=>finish());}); +}); diff --git a/frontend/src/components/Auth/FirstRunSetup.tsx b/frontend/src/components/Auth/FirstRunSetup.tsx new file mode 100644 index 0000000..637e034 --- /dev/null +++ b/frontend/src/components/Auth/FirstRunSetup.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { useAuth } from '../../context/AuthContext'; + +const inputStyle:React.CSSProperties={display:'block',width:'100%',boxSizing:'border-box',padding:10,margin:'6px 0 16px'}; +export default function FirstRunSetup():React.ReactElement{ + const{completeSetup}=useAuth();const[username,setUsername]=React.useState(''),[displayName,setDisplayName]=React.useState(''),[email,setEmail]=React.useState(''),[password,setPassword]=React.useState(''),[confirm,setConfirm]=React.useState(''),[error,setError]=React.useState(''),[busy,setBusy]=React.useState(false); + const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(password!==confirm){setError('Passwords do not match.');return;}setBusy(true);try{await completeSetup({username,password,displayName,email});}catch(x){setError(x instanceof Error?x.message:'Initial setup failed.');}finally{setBusy(false)}}; + return

Set up Conductor

Create the first administrator for this installation. Additional users can be added later from User management.

Use at least 12 characters. The password is submitted securely and is never displayed again.

{error&&

{error}

}
; +} diff --git a/frontend/src/components/Layout/Header.tsx b/frontend/src/components/Layout/Header.tsx index 2676a35..10ecfe8 100644 --- a/frontend/src/components/Layout/Header.tsx +++ b/frontend/src/components/Layout/Header.tsx @@ -10,7 +10,7 @@ function Header(): React.ReactElement { Conductor REST UI Builder - {user?.username} ({user?.role === 'admin' ? 'Administrator' : 'User'}) + {user?.displayName || user?.username} ({user?.role === 'admin' ? 'Administrator' : 'User'}) ); } diff --git a/frontend/src/components/Published/AppCatalog.tsx b/frontend/src/components/Published/AppCatalog.tsx index 3198b4b..4eeb0f6 100644 --- a/frontend/src/components/Published/AppCatalog.tsx +++ b/frontend/src/components/Published/AppCatalog.tsx @@ -1,2 +1,2 @@ -import React from 'react'; import { listPublished, type PublishedSummary } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext'; -export default function AppCatalog():React.ReactElement{const{user,logout}=useAuth();const[apps,setApps]=React.useState([]);const[error,setError]=React.useState('');React.useEffect(()=>{listPublished().then(setApps).catch(e=>setError(String(e)));},[]);return

Applications

Signed in as {user?.displayName}

{error&&

{error}

}
{apps.map(app=>{app.displayName}

{app.description}

{app.visibility==='public'?'Public':'Sign-in required'} · version {app.version}
)}{!apps.length&&!error&&

No applications are currently published.

}
} +import React from 'react'; import { listPublished, type PublishedSummary } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext'; import ChangePassword from '../Auth/ChangePassword'; +export default function AppCatalog():React.ReactElement{const{user,logout}=useAuth();const[apps,setApps]=React.useState([]);const[error,setError]=React.useState('');React.useEffect(()=>{listPublished().then(setApps).catch(e=>setError(String(e)));},[]);return

Applications

Signed in as {user?.displayName||user?.username}

{error&&

{error}

}
{apps.map(app=>{app.displayName}

{app.description}

{app.visibility==='public'?'Public':'Sign-in required'} · version {app.version}
)}{!apps.length&&!error&&

No applications are currently published.

}
} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 9e91980..f11caad 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,18 +1,22 @@ import React from 'react'; import { apiFetch } from '../api/apiClient'; -export type CurrentUser = { id: string; username: string; displayName: string; role: 'admin' | 'user' }; -type AuthValue = { user: CurrentUser | null; loading: boolean; login: (username: string, password: string) => Promise; logout: () => Promise; refresh: () => Promise }; +export type CurrentUser = { id: string; username: string; displayName: string | null; email: string | null; role: 'admin' | 'user' }; +type SetupInput={username:string;password:string;displayName?:string;email?:string}; +type AuthValue = { user: CurrentUser | null; loading: boolean; setupRequired: boolean; login: (username: string, password: string) => Promise; completeSetup:(input:SetupInput)=>Promise; logout: () => Promise; refresh: () => Promise }; const AuthContext = React.createContext(null); export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement { const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); - const normalize=(value:CurrentUser):CurrentUser=>({...value,id:value.id??(value as CurrentUser&{userId:string}).userId,displayName:value.displayName??value.username}); - const refresh = React.useCallback(async () => { const response = await apiFetch('/api/auth/me'); setUser(response.ok ? normalize(((await response.json()) as { user: CurrentUser }).user) : null); setLoading(false); }, []); + const [setupRequired,setSetupRequired]=React.useState(false); + const [setupToken,setSetupToken]=React.useState(''); + const normalize=(value:CurrentUser):CurrentUser=>({...value,id:value.id??(value as CurrentUser&{userId:string}).userId,displayName:value.displayName??null,email:value.email??null}); + const refresh = React.useCallback(async () => {setLoading(true);const setupResponse=await apiFetch('/api/auth/setup');const setup=(await setupResponse.json()) as {setupRequired:boolean;setupToken?:string};setSetupRequired(setup.setupRequired);setSetupToken(setup.setupToken??'');if(setup.setupRequired){setUser(null);setLoading(false);return;}const response = await apiFetch('/api/auth/me');setUser(response.ok?normalize(((await response.json()) as {user:CurrentUser}).user):null);setLoading(false);}, []); React.useEffect(() => { void refresh(); }, [refresh]); const login = async (username: string, password: string) => { const response = await apiFetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) throw new Error('Invalid username or password.'); setUser(normalize(((await response.json()) as { user: CurrentUser }).user)); }; + const completeSetup=async(input:SetupInput)=>{const response=await apiFetch('/api/auth/setup',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':setupToken},body:JSON.stringify(input)});if(!response.ok){const body=await response.json().catch(()=>({})) as {error?:string;code?:string};if(body.code==='SETUP_UNAVAILABLE')await refresh();throw new Error(body.error??'Initial setup could not be completed.');}setUser(normalize(((await response.json()) as {user:CurrentUser}).user));setSetupRequired(false);setSetupToken('');}; const logout = async () => { await apiFetch('/api/auth/logout', { method: 'POST' }); setUser(null); }; - return {children}; + return {children}; } export function useAuth(): AuthValue { const value = React.useContext(AuthContext); if (!value) throw new Error('AuthProvider is missing.'); return value; } diff --git a/scripts/check-mvp-governance.mjs b/scripts/check-mvp-governance.mjs index 6ae34ca..3edb383 100644 --- a/scripts/check-mvp-governance.mjs +++ b/scripts/check-mvp-governance.mjs @@ -48,7 +48,7 @@ for (const file of linkedFiles) { } } -for (let id = 1; id <= 17; id += 1) { +for (let id = 1; id <= 18; id += 1) { if (!traceability.includes(`| R${id} |`)) failures.push(`docs/MVP_TRACEABILITY.md: missing R${id}`); } @@ -57,4 +57,4 @@ if (failures.length > 0) { process.exit(1); } -console.log('MVP governance check passed: scope, R1-R17 traceability, classifications, and links are consistent.'); +console.log('MVP governance check passed: scope, R1-R18 traceability, classifications, and links are consistent.');