Add browser first-run administration
This commit is contained in:
parent
102e0dc46c
commit
a3372b33c2
@ -83,7 +83,7 @@ Approved on 2026-08-07 for Slice 7a:
|
|||||||
|
|
||||||
## Release Boundary
|
## 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`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
14
ROADMAP.md
14
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 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 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 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 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 |
|
| [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.
|
- [ ] Preserve the CLI bootstrap only as a documented emergency/recovery path.
|
||||||
- [ ] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage.
|
- [ ] 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
|
## 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
|
### Slice 8 — Documentation ownership and reconciliation
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,8 @@ export function initDatabase(): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
normalized_username TEXT NOT NULL UNIQUE,
|
normalized_username TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT,
|
||||||
|
email TEXT,
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
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('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');
|
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');
|
console.log('Database initialised');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.');
|
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');
|
const hash = (value:string) => createHmac('sha256',sessionKey).update(value).digest('base64url');
|
||||||
export type SessionTokens = { sessionToken:string; csrfToken:string; expiresAt:string };
|
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 {
|
export function createSession(userId:string):SessionTokens {
|
||||||
const sessionToken=randomBytes(32).toString('base64url'); const csrfToken=randomBytes(32).toString('base64url');
|
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};
|
return {sessionToken,csrfToken,expiresAt};
|
||||||
}
|
}
|
||||||
export function resolveSession(token:string):SessionPrincipalRow|undefined {
|
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 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)); }
|
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)); }
|
||||||
|
|||||||
@ -2,25 +2,27 @@ import { randomUUID } from 'crypto';
|
|||||||
import db from './database';
|
import db from './database';
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'user';
|
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 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; role:UserRole; enabled:boolean; provider:string; createdAt:string; updatedAt:string; lastLoginAt: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 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 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 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 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 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 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 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)!;
|
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;
|
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);
|
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); }
|
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); }
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
import type { UserRole } from '../db/users';
|
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<TInput=unknown> { authenticate(input:TInput):Promise<AuthenticatedPrincipal|null>; }
|
export interface AuthenticationProvider<TInput=unknown> { authenticate(input:TInput):Promise<AuthenticatedPrincipal|null>; }
|
||||||
|
|||||||
@ -5,6 +5,6 @@ import type { AuthenticationProvider, AuthenticatedPrincipal } from './authProvi
|
|||||||
export class LocalAuthenticationProvider implements AuthenticationProvider<{username:string;password:string}> {
|
export class LocalAuthenticationProvider implements AuthenticationProvider<{username:string;password:string}> {
|
||||||
async authenticate(input:{username:string;password:string}):Promise<AuthenticatedPrincipal|null> {
|
async authenticate(input:{username:string;password:string}):Promise<AuthenticatedPrincipal|null> {
|
||||||
const user=findLocalUser(input.username); if(!user||user.enabled!==1||!await verifyPassword(input.password,user.password_hash))return null;
|
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'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
export function optionalAuth(req:Request,_res:Response,next:NextFunction):void {
|
||||||
const token=cookie(req,'conductor_session'); if(!token){next();return;} const row=resolveSession(token);
|
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;}
|
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 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();}); }
|
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();}); }
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { createServer, type IncomingMessage } from 'node:http';
|
||||||
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@ -105,8 +105,6 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
processChild.kill('SIGTERM');
|
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();
|
let child = spawnBackend();
|
||||||
|
|
||||||
t.after(async () => {
|
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.response.status, 200);
|
||||||
assert.equal(health.body.status, 'ok');
|
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' }) });
|
const setupStatus = await jsonRequest(backendUrl, '/api/auth/setup');
|
||||||
assert.equal(login.response.status, 200);
|
assert.equal(setupStatus.body.setupRequired, true);
|
||||||
csrfToken = login.body.csrfToken;
|
assert.ok(setupStatus.body.setupToken);
|
||||||
const setCookie = login.response.headers.get('set-cookie') ?? '';
|
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('; ');
|
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 validDoc = projectDocument();
|
||||||
const validation = await jsonRequest(backendUrl, '/api/projects/validate', {
|
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);
|
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' }) });
|
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);
|
assert.equal(userLoginResponse.status, 200);
|
||||||
|
const userLoginBody=await userLoginResponse.json() as any;
|
||||||
const userSetCookie = userLoginResponse.headers.get('set-cookie') ?? '';
|
const userSetCookie = userLoginResponse.headers.get('set-cookie') ?? '';
|
||||||
const userCookie = `conductor_session=${userSetCookie.match(/conductor_session=([^;]+)/)?.[1]}`;
|
const userCookie = `conductor_session=${userSetCookie.match(/conductor_session=([^;]+)/)?.[1]}`;
|
||||||
assert.equal((await fetch(`${backendUrl}/api/projects`, { headers: { cookie: userCookie } })).status, 403);
|
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' }) });
|
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));
|
assert.equal(publication.response.status, 201, JSON.stringify(publication.body));
|
||||||
const savedAdminCookie = authCookie; authCookie = '';
|
const savedAdminCookie = authCookie; authCookie = '';
|
||||||
|
|||||||
@ -1,17 +1,76 @@
|
|||||||
|
import { randomBytes, timingSafeEqual } from 'crypto';
|
||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { LocalAuthenticationProvider } from '../lib/localAuthProvider';
|
import { LocalAuthenticationProvider } from '../lib/localAuthProvider';
|
||||||
import { createSession, revokeSession } from '../db/sessions';
|
import { createSession, revokeSession } from '../db/sessions';
|
||||||
import { clearCsrfCookie, clearSessionCookie, csrfCookie, optionalAuth, requireAuth, requireCsrf, sessionCookie } from '../middleware/auth';
|
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 router=Router();
|
||||||
const attempts=new Map<string,{count:number;reset:number}>(); const LIMIT=5, WINDOW=60_000;
|
const provider=new LocalAuthenticationProvider();
|
||||||
function attemptKey(req:Request,username:string):string { return `${req.ip}:${username.trim().toLocaleLowerCase('en-US')}`; }
|
const attempts=new Map<string,{count:number;reset:number}>();
|
||||||
router.post('/login',async(req:Request,res:Response)=>{
|
const setupAttempts=new Map<string,{count:number;reset:number}>();
|
||||||
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);
|
const LIMIT=5, WINDOW=60_000;
|
||||||
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 clientKey=(req:Request)=>req.ip??req.socket.remoteAddress??'unknown';
|
||||||
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;}
|
function attemptKey(req:Request,username:string):string{return `${clientKey(req)}:${username.trim().toLocaleLowerCase('en-US')}`;}
|
||||||
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});
|
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();});
|
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;
|
export default router;
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import { revokeUserSessions } from '../db/sessions';
|
|||||||
import { hashPassword, validatePassword } from '../lib/passwords';
|
import { hashPassword, validatePassword } from '../lib/passwords';
|
||||||
const router=Router();
|
const router=Router();
|
||||||
const role=(value:unknown):value is UserRole=>value==='admin'||value==='user';
|
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.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.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;}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.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/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();});
|
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;
|
export default router;
|
||||||
|
|||||||
@ -1,4 +1,24 @@
|
|||||||
import { initDatabase } from '../db/init';
|
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';
|
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;});
|
||||||
|
|||||||
34
docs/FIRST_RUN_SETUP.md
Normal file
34
docs/FIRST_RUN_SETUP.md
Normal file
@ -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.
|
||||||
@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Approved acceptance workflows
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import AppCatalog from './components/Published/AppCatalog';
|
|||||||
import PublishedApp from './components/Published/PublishedApp';
|
import PublishedApp from './components/Published/PublishedApp';
|
||||||
import UserManagement from './components/Admin/UserManagement';
|
import UserManagement from './components/Admin/UserManagement';
|
||||||
import Publishing from './components/Admin/Publishing';
|
import Publishing from './components/Admin/Publishing';
|
||||||
|
import FirstRunSetup from './components/Auth/FirstRunSetup';
|
||||||
|
|
||||||
// Placeholder panels — replaced with real implementations in later steps
|
// Placeholder panels — replaced with real implementations in later steps
|
||||||
function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement {
|
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.
|
// ProjectProvider wraps the entire app so all editor views share one context.
|
||||||
function AuthenticatedApp(): React.ReactElement {
|
function AuthenticatedApp(): React.ReactElement {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading, setupRequired } = useAuth();
|
||||||
const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)\/?$/);
|
const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)\/?$/);
|
||||||
if (publishedMatch) return <ProjectProvider><PublishedApp slug={decodeURIComponent(publishedMatch[1])}/></ProjectProvider>;
|
if (publishedMatch) return <ProjectProvider><PublishedApp slug={decodeURIComponent(publishedMatch[1])}/></ProjectProvider>;
|
||||||
if (loading) return <p style={{padding:32}}>Loading…</p>;
|
if (loading) return <p style={{padding:32}}>Loading…</p>;
|
||||||
|
if (setupRequired) return <FirstRunSetup />;
|
||||||
if (!user) return <Login />;
|
if (!user) return <Login />;
|
||||||
if (user.role === 'user') return <AppCatalog />;
|
if (user.role === 'user') return <AppCatalog />;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,20 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { apiFetch } from '../../api/apiClient';
|
import { apiFetch } from '../../api/apiClient';
|
||||||
|
type User={id:string;username:string;displayName:string|null;email:string|null;role:'admin'|'user';enabled:boolean;lastLoginAt?:string};
|
||||||
type User={id:string;username:string;role:'admin'|'user';enabled:boolean;lastLoginAt?:string};
|
|
||||||
async function request<T>(url:string,init?:RequestInit):Promise<T>{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<T>}
|
async function request<T>(url:string,init?:RequestInit):Promise<T>{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<T>}
|
||||||
|
|
||||||
export default function UserManagement():React.ReactElement {
|
export default function UserManagement():React.ReactElement{
|
||||||
const[users,setUsers]=React.useState<User[]>([]),[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<User|null>(null),[resetPassword,setResetPassword]=React.useState(''),[resetConfirm,setResetConfirm]=React.useState('');
|
const[users,setUsers]=React.useState<User[]>([]),[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<User|null>(null),[resetPassword,setResetPassword]=React.useState(''),[resetConfirm,setResetConfirm]=React.useState('');
|
||||||
const load=()=>request<User[]>('/api/admin/users').then(setUsers).catch(e=>setError(String(e)));
|
const load=React.useCallback(()=>request<User[]>('/api/admin/users').then(setUsers).catch(e=>setError(String(e))),[]);React.useEffect(()=>{void load()},[load]);
|
||||||
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,displayName,email,password,role})});setUsername('');setDisplayName('');setEmail('');setPassword('');setConfirm('');void load()}catch(x){setError(x instanceof Error?x.message:String(x))}};
|
||||||
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<User>)=>{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 update=async(user:User,changes:Partial<User>)=>{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 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)));
|
||||||
const revoke=(user:User)=>request(`/api/admin/users/${user.id}/revoke-sessions`,{method:'POST'}).catch(e=>setError(String(e)));
|
return <section><h2>User management</h2><p>Usernames identify accounts. Display name and contact address are optional profile information.</p>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}
|
||||||
return <section><h2>User management</h2><p>Create local accounts, assign roles, disable access, and revoke active sessions.</p>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}
|
<form onSubmit={create} style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(180px,1fr))',gap:8,padding:16,border:'1px solid #d0d7de',borderRadius:8}}><input required aria-label="Username" placeholder="Username" value={username} onChange={e=>setUsername(e.target.value)}/><input aria-label="Display name" placeholder="Display name (optional)" value={displayName} onChange={e=>setDisplayName(e.target.value)}/><input type="email" aria-label="Contact email" placeholder="Contact email (optional)" value={email} onChange={e=>setEmail(e.target.value)}/><input required aria-label="Temporary password" type="password" minLength={12} autoComplete="new-password" placeholder="Temporary password (12+ chars)" value={password} onChange={e=>setPassword(e.target.value)}/><input required aria-label="Confirm temporary password" type="password" minLength={12} autoComplete="new-password" placeholder="Confirm password" value={confirm} onChange={e=>setConfirm(e.target.value)}/><select aria-label="New user role" value={role} onChange={e=>setRole(e.target.value as 'admin'|'user')}><option value="user">User</option><option value="admin">Admin</option></select><button>Create user</button></form>
|
||||||
<form onSubmit={create} style={{display:'flex',gap:8,flexWrap:'wrap',padding:16,border:'1px solid #d0d7de',borderRadius:8}}><input required aria-label="Username" placeholder="Username" value={username} onChange={e=>setUsername(e.target.value)}/><input required aria-label="Temporary password" type="password" minLength={12} autoComplete="new-password" placeholder="Temporary password (12+ chars)" value={password} onChange={e=>setPassword(e.target.value)}/><input required aria-label="Confirm temporary password" type="password" minLength={12} autoComplete="new-password" placeholder="Confirm password" value={confirm} onChange={e=>setConfirm(e.target.value)}/><select aria-label="New user role" value={role} onChange={e=>setRole(e.target.value as 'admin'|'user')}><option value="user">User</option><option value="admin">Admin</option></select><button>Create user</button></form>
|
<div style={{display:'grid',gap:10,marginTop:16}}>{users.map(user=><div key={user.id} style={{padding:14,border:'1px solid #d0d7de',borderRadius:8}}><div style={{display:'flex',alignItems:'center',gap:12,flexWrap:'wrap'}}><strong style={{minWidth:180}}>{user.username}</strong><select aria-label={`Role for ${user.username}`} value={user.role} onChange={e=>void update(user,{role:e.target.value as User['role']})}><option value="user">User</option><option value="admin">Admin</option></select><label><input type="checkbox" checked={user.enabled} onChange={e=>void update(user,{enabled:e.target.checked})}/> Enabled</label><button onClick={()=>{setError('');setResetUser(user)}}>Reset password</button><button onClick={()=>void revoke(user)}>Revoke sessions</button></div><div style={{display:'flex',gap:8,marginTop:10,flexWrap:'wrap'}}><label>Display name <input defaultValue={user.displayName??''} onBlur={e=>{if(e.target.value!==(user.displayName??''))void update(user,{displayName:e.target.value})}}/></label><label>Contact email <input type="email" defaultValue={user.email??''} onBlur={e=>{if(e.target.value!==(user.email??''))void update(user,{email:e.target.value})}}/></label></div></div>)}</div>
|
||||||
<div style={{display:'grid',gap:10,marginTop:16}}>{users.map(user=><div key={user.id} style={{display:'flex',alignItems:'center',gap:12,padding:14,border:'1px solid #d0d7de',borderRadius:8}}><strong style={{minWidth:180}}>{user.username}</strong><select aria-label={`Role for ${user.username}`} value={user.role} onChange={e=>void update(user,{role:e.target.value as User['role']})}><option value="user">User</option><option value="admin">Admin</option></select><label><input type="checkbox" checked={user.enabled} onChange={e=>void update(user,{enabled:e.target.checked})}/> Enabled</label><button onClick={()=>{setError('');setResetUser(user)}}>Reset password</button><button onClick={()=>void revoke(user)}>Revoke sessions</button></div>)}</div>
|
{resetUser&&<div role="dialog" aria-modal="true" aria-labelledby="reset-title" style={{position:'fixed',inset:0,background:'rgba(0,0,0,.45)',display:'grid',placeItems:'center',zIndex:20}}><form onSubmit={submitReset} style={{width:380,maxWidth:'calc(100vw - 48px)',background:'#fff',padding:24,borderRadius:8}}><h3 id="reset-title">Reset password for {resetUser.username}</h3><p>Enter the new password twice. It must contain at least 12 characters.</p><label>New password<input autoFocus required type="password" minLength={12} autoComplete="new-password" value={resetPassword} onChange={e=>setResetPassword(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',margin:'6px 0 14px'}}/></label><label>Confirm new password<input required type="password" minLength={12} autoComplete="new-password" value={resetConfirm} onChange={e=>setResetConfirm(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',margin:'6px 0 14px'}}/></label><div style={{display:'flex',gap:8,justifyContent:'flex-end'}}><button type="button" onClick={()=>{setResetUser(null);setResetPassword('');setResetConfirm('')}}>Cancel</button><button>Reset password</button></div></form></div>}
|
||||||
{resetUser&&<div role="dialog" aria-modal="true" aria-labelledby="reset-title" style={{position:'fixed',inset:0,background:'rgba(0,0,0,.45)',display:'grid',placeItems:'center',zIndex:20}}><form onSubmit={submitReset} style={{width:380,maxWidth:'calc(100vw - 48px)',background:'#fff',padding:24,borderRadius:8,boxShadow:'0 8px 32px rgba(0,0,0,.25)'}}><h3 id="reset-title">Reset password for {resetUser.username}</h3><p>Enter the new password twice. It must contain at least 12 characters.</p><label>New password<input autoFocus required type="password" minLength={12} autoComplete="new-password" value={resetPassword} onChange={e=>setResetPassword(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',margin:'6px 0 14px'}}/></label><label>Confirm new password<input required type="password" minLength={12} autoComplete="new-password" value={resetConfirm} onChange={e=>setResetConfirm(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',margin:'6px 0 14px'}}/></label><div style={{display:'flex',gap:8,justifyContent:'flex-end'}}><button type="button" onClick={()=>{setResetUser(null);setResetPassword('');setResetConfirm('')}}>Cancel</button><button>Reset password</button></div></form></div>}
|
</section>;
|
||||||
</section>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
frontend/src/components/Auth/ChangePassword.test.tsx
Normal file
4
frontend/src/components/Auth/ChangePassword.test.tsx
Normal file
@ -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(<ChangePassword/>));const inputs=container.querySelectorAll<HTMLInputElement>('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(<ChangePassword/>));const inputs=container.querySelectorAll<HTMLInputElement>('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.');});});
|
||||||
9
frontend/src/components/Auth/ChangePassword.tsx
Normal file
9
frontend/src/components/Auth/ChangePassword.tsx
Normal file
@ -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 <details style={{marginTop:28,border:'1px solid #d0d7de',borderRadius:8,padding:16,maxWidth:480}}><summary style={{cursor:'pointer',fontWeight:600}}>Change my password</summary><form onSubmit={submit} style={{marginTop:16}}><label>Current password<input required type="password" autoComplete="current-password" value={currentPassword} onChange={e=>setCurrentPassword(e.target.value)} style={field}/></label><label>New password<input required type="password" minLength={12} autoComplete="new-password" value={newPassword} onChange={e=>setNewPassword(e.target.value)} style={field}/></label><label>Confirm new password<input required type="password" minLength={12} autoComplete="new-password" value={confirm} onChange={e=>setConfirm(e.target.value)} style={field}/></label><p style={{fontSize:13,color:'#57606a'}}>Use at least 12 characters. Changing it signs out your other sessions.</p>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}{success&&<p role="status" style={{color:'#1a7f37'}}>{success}</p>}<button disabled={busy}>{busy?'Changing password…':'Change password'}</button></form></details>;
|
||||||
|
}
|
||||||
18
frontend/src/components/Auth/FirstRunSetup.test.tsx
Normal file
18
frontend/src/components/Auth/FirstRunSetup.test.tsx
Normal file
@ -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(<FirstRunSetup/>));const inputs=container.querySelectorAll<HTMLInputElement>('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(<FirstRunSetup/>));const inputs=container.querySelectorAll<HTMLInputElement>('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<void>(resolve=>{finish=resolve;}));await act(async()=>root.render(<FirstRunSetup/>));const inputs=container.querySelectorAll<HTMLInputElement>('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<HTMLButtonElement>('button')!.disabled).toBe(true);expect(completeSetup).toHaveBeenCalledTimes(1);await act(async()=>finish());});
|
||||||
|
});
|
||||||
9
frontend/src/components/Auth/FirstRunSetup.tsx
Normal file
9
frontend/src/components/Auth/FirstRunSetup.tsx
Normal file
@ -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 <main style={{maxWidth:480,margin:'6vh auto',padding:32,border:'1px solid #d0d7de',borderRadius:8,background:'#fff'}}><h1 style={{marginTop:0}}>Set up Conductor</h1><p>Create the first administrator for this installation. Additional users can be added later from User management.</p><form onSubmit={submit}><label>Username<input required autoFocus autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} style={inputStyle}/></label><label>Display name <small>(optional)</small><input autoComplete="name" value={displayName} onChange={e=>setDisplayName(e.target.value)} style={inputStyle}/></label><label>Email or contact address <small>(optional)</small><input type="email" autoComplete="email" value={email} onChange={e=>setEmail(e.target.value)} style={inputStyle}/></label><label>Password<input required type="password" minLength={12} autoComplete="new-password" value={password} onChange={e=>setPassword(e.target.value)} style={inputStyle}/></label><label>Confirm password<input required type="password" minLength={12} autoComplete="new-password" value={confirm} onChange={e=>setConfirm(e.target.value)} style={inputStyle}/></label><p style={{fontSize:13,color:'#57606a'}}>Use at least 12 characters. The password is submitted securely and is never displayed again.</p>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}<button disabled={busy} style={{padding:'10px 18px'}}>{busy?'Creating administrator…':'Create administrator'}</button></form></main>;
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ function Header(): React.ReactElement {
|
|||||||
<span className={styles.logoAccent}>Conductor</span>
|
<span className={styles.logoAccent}>Conductor</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.tagline}>REST UI Builder</span>
|
<span className={styles.tagline}>REST UI Builder</span>
|
||||||
<span style={{marginLeft:'auto',fontSize:12,color:'#c9d1d9'}}>{user?.username} ({user?.role === 'admin' ? 'Administrator' : 'User'}) <button onClick={()=>void logout()} style={{marginLeft:12}}>Sign out</button></span>
|
<span style={{marginLeft:'auto',fontSize:12,color:'#c9d1d9'}}>{user?.displayName || user?.username} ({user?.role === 'admin' ? 'Administrator' : 'User'}) <button onClick={()=>void logout()} style={{marginLeft:12}}>Sign out</button></span>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
import React from 'react'; import { listPublished, type PublishedSummary } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext';
|
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<PublishedSummary[]>([]);const[error,setError]=React.useState('');React.useEffect(()=>{listPublished().then(setApps).catch(e=>setError(String(e)));},[]);return <main style={{maxWidth:900,margin:'40px auto',padding:24}}><div style={{display:'flex',justifyContent:'space-between'}}><div><h1>Applications</h1><p>Signed in as {user?.displayName}</p></div><button onClick={()=>void logout()} style={{height:38}}>Sign out</button></div>{error&&<p role="alert">{error}</p>}<div style={{display:'grid',gap:16}}>{apps.map(app=><a key={app.slug} href={`/apps/${app.slug}`} style={{display:'block',padding:20,border:'1px solid #d0d7de',borderRadius:8,color:'inherit',textDecoration:'none'}}><strong>{app.displayName}</strong><p>{app.description}</p><small>{app.visibility==='public'?'Public':'Sign-in required'} · version {app.version}</small></a>)}{!apps.length&&!error&&<p>No applications are currently published.</p>}</div></main>}
|
export default function AppCatalog():React.ReactElement{const{user,logout}=useAuth();const[apps,setApps]=React.useState<PublishedSummary[]>([]);const[error,setError]=React.useState('');React.useEffect(()=>{listPublished().then(setApps).catch(e=>setError(String(e)));},[]);return <main style={{maxWidth:900,margin:'40px auto',padding:24}}><div style={{display:'flex',justifyContent:'space-between'}}><div><h1>Applications</h1><p>Signed in as {user?.displayName||user?.username}</p></div><button onClick={()=>void logout()} style={{height:38}}>Sign out</button></div>{error&&<p role="alert">{error}</p>}<div style={{display:'grid',gap:16}}>{apps.map(app=><a key={app.slug} href={`/apps/${app.slug}`} style={{display:'block',padding:20,border:'1px solid #d0d7de',borderRadius:8,color:'inherit',textDecoration:'none'}}><strong>{app.displayName}</strong><p>{app.description}</p><small>{app.visibility==='public'?'Public':'Sign-in required'} · version {app.version}</small></a>)}{!apps.length&&!error&&<p>No applications are currently published.</p>}</div><ChangePassword/></main>}
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { apiFetch } from '../api/apiClient';
|
import { apiFetch } from '../api/apiClient';
|
||||||
|
|
||||||
export type CurrentUser = { id: string; username: string; displayName: string; role: 'admin' | 'user' };
|
export type CurrentUser = { id: string; username: string; displayName: string | null; email: string | null; role: 'admin' | 'user' };
|
||||||
type AuthValue = { user: CurrentUser | null; loading: boolean; login: (username: string, password: string) => Promise<void>; logout: () => Promise<void>; refresh: () => Promise<void> };
|
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<void>; completeSetup:(input:SetupInput)=>Promise<void>; logout: () => Promise<void>; refresh: () => Promise<void> };
|
||||||
const AuthContext = React.createContext<AuthValue | null>(null);
|
const AuthContext = React.createContext<AuthValue | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||||
const [user, setUser] = React.useState<CurrentUser | null>(null);
|
const [user, setUser] = React.useState<CurrentUser | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
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 [setupRequired,setSetupRequired]=React.useState(false);
|
||||||
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 [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]);
|
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 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); };
|
const logout = async () => { await apiFetch('/api/auth/logout', { method: 'POST' }); setUser(null); };
|
||||||
return <AuthContext.Provider value={{ user, loading, login, logout, refresh }}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={{ user, loading, setupRequired, login, completeSetup, logout, refresh }}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
export function useAuth(): AuthValue { const value = React.useContext(AuthContext); if (!value) throw new Error('AuthProvider is missing.'); return value; }
|
export function useAuth(): AuthValue { const value = React.useContext(AuthContext); if (!value) throw new Error('AuthProvider is missing.'); return value; }
|
||||||
|
|||||||
@ -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}`);
|
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);
|
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.');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user