session 2026-07-18: Electron local dev fixes, nav z-index, board/outline/history update
Electron fixes: - extractToken (auth-connector): Bearer header checked before cookie — prevents stale Electron session cookie from winning over injected token - JWT_CLOCK_TOLERANCE env var: passed to jwt.verify as clockTolerance; set to 7200 in .env to absorb ~65 min clock skew between Windows dev machine and prod auth server - Startup token refresh: main.ts calls POST /api/auth/refresh before opening main window; shows login on failure instead of opening with expired tokens - Quit-on-login fix: createMainWindow() is async; login-success now awaits it before closing the login window (zero windows → app.quit() race was killing the process) - Credential persistence: email + password stored in userData; remember-me checkbox controls save behaviour; clear button removes saved files; fields pre-fill on load - Electron session cookies cleared in createMainWindow() to avoid stale token reuse CSS: z-index: 10 on .pld-nav (project-list-default) — fixed mobile nav buried under rows Boards: cleared Done lane, added Electron fixes and nav z-index entries; backlog MVP entry for local testing solution; bugs.html: 401-handling bug moved to Done. Outline: auth card updated with extractToken order note and JWT_CLOCK_TOLERANCE docs. History: Friday 18 July entry expanded with session 2 cards. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e3fe19395
commit
4a72363aaf
|
|
@ -45,7 +45,8 @@
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input[type="email"],
|
||||||
|
input[type="password"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
|
|
@ -58,9 +59,53 @@
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus { border-color: #555; }
|
input[type="email"]:focus,
|
||||||
|
input[type="password"]:focus { border-color: #555; }
|
||||||
|
|
||||||
button {
|
.remember-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #777;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-label input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #555;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear:hover { color: #e05555; background: none; }
|
||||||
|
.btn-clear:disabled { display: none; }
|
||||||
|
|
||||||
|
button#btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.7rem;
|
padding: 0.7rem;
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
|
|
@ -74,8 +119,8 @@
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover { background: #fff; }
|
button#btn:hover { background: #fff; }
|
||||||
button:disabled { background: #333; color: #666; cursor: default; }
|
button#btn:disabled { background: #333; color: #666; cursor: default; }
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
|
|
@ -97,6 +142,14 @@
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" type="password" placeholder="••••••••" autocomplete="current-password">
|
<input id="password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||||
|
|
||||||
|
<div class="remember-row">
|
||||||
|
<label class="remember-label">
|
||||||
|
<input type="checkbox" id="remember" checked>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
<button class="btn-clear" id="btn-clear">Clear saved</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button id="btn">Sign in</button>
|
<button id="btn">Sign in</button>
|
||||||
<p class="error" id="error"></p>
|
<p class="error" id="error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,7 +157,9 @@
|
||||||
<script>
|
<script>
|
||||||
const emailEl = document.getElementById( 'email' );
|
const emailEl = document.getElementById( 'email' );
|
||||||
const passwordEl = document.getElementById( 'password' );
|
const passwordEl = document.getElementById( 'password' );
|
||||||
|
const rememberEl = document.getElementById( 'remember' );
|
||||||
const btn = document.getElementById( 'btn' );
|
const btn = document.getElementById( 'btn' );
|
||||||
|
const btnClear = document.getElementById( 'btn-clear' );
|
||||||
const errorEl = document.getElementById( 'error' );
|
const errorEl = document.getElementById( 'error' );
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
|
@ -116,7 +171,7 @@
|
||||||
btn.textContent = 'Signing in…';
|
btn.textContent = 'Signing in…';
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
const result = await window.electronAuth.login( email, password );
|
const result = await window.electronAuth.login( email, password, rememberEl.checked );
|
||||||
|
|
||||||
if ( result.ok ) {
|
if ( result.ok ) {
|
||||||
window.electronAuth.loginSuccess();
|
window.electronAuth.loginSuccess();
|
||||||
|
|
@ -127,10 +182,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
btnClear.addEventListener( 'click', async () => {
|
||||||
|
await window.electronAuth.clearCredentials();
|
||||||
|
emailEl.value = '';
|
||||||
|
passwordEl.value = '';
|
||||||
|
rememberEl.checked = true;
|
||||||
|
btnClear.disabled = true;
|
||||||
|
emailEl.focus();
|
||||||
|
} );
|
||||||
|
|
||||||
btn.addEventListener( 'click', login );
|
btn.addEventListener( 'click', login );
|
||||||
document.addEventListener( 'keydown', ( e ) => { if ( e.key === 'Enter' ) login(); } );
|
document.addEventListener( 'keydown', ( e ) => { if ( e.key === 'Enter' ) login(); } );
|
||||||
|
|
||||||
emailEl.focus();
|
Promise.all( [ window.electronAuth.lastEmail(), window.electronAuth.lastPassword() ] )
|
||||||
|
.then( ( [ email, password ] ) => {
|
||||||
|
const hasSaved = !!email;
|
||||||
|
if ( email ) emailEl.value = email;
|
||||||
|
if ( password ) passwordEl.value = password;
|
||||||
|
btnClear.disabled = !hasSaved;
|
||||||
|
if ( email && password ) btn.focus();
|
||||||
|
else if ( email ) passwordEl.focus();
|
||||||
|
else emailEl.focus();
|
||||||
|
} );
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,16 @@ import https from 'https';
|
||||||
const AUTH_HOST = 'https://account.rokojori.com';
|
const AUTH_HOST = 'https://account.rokojori.com';
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
|
|
||||||
|
// ── Persistence helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function tokenFile(): string {
|
function tokenFile(): string {
|
||||||
return path.join( app.getPath( 'userData' ), 'tokens.json' );
|
return path.join( app.getPath( 'userData' ), 'tokens.json' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emailFile(): string {
|
||||||
|
return path.join( app.getPath( 'userData' ), 'last-email.txt' );
|
||||||
|
}
|
||||||
|
|
||||||
interface Tokens {
|
interface Tokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|
@ -32,6 +38,33 @@ function clearTokens(): void {
|
||||||
try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ }
|
try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function passwordFile(): string {
|
||||||
|
return path.join( app.getPath( 'userData' ), 'last-password.txt' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLastEmail(): string {
|
||||||
|
try { return fs.readFileSync( emailFile(), 'utf-8' ).trim(); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLastEmail( email: string ): void {
|
||||||
|
fs.writeFileSync( emailFile(), email, 'utf-8' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLastPassword(): string {
|
||||||
|
try { return fs.readFileSync( passwordFile(), 'utf-8' ).trim(); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLastPassword( password: string ): void {
|
||||||
|
fs.writeFileSync( passwordFile(), password, 'utf-8' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCredentials(): void {
|
||||||
|
try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ }
|
||||||
|
try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Network ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function postJson( url: string, body: unknown ): Promise<unknown> {
|
function postJson( url: string, body: unknown ): Promise<unknown> {
|
||||||
return new Promise( ( resolve, reject ) => {
|
return new Promise( ( resolve, reject ) => {
|
||||||
const data = JSON.stringify( body );
|
const data = JSON.stringify( body );
|
||||||
|
|
@ -61,6 +94,8 @@ function postJson( url: string, body: unknown ): Promise<unknown> {
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Header injection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function registerHeaderInjector( getToken: () => string | null ): void {
|
function registerHeaderInjector( getToken: () => string | null ): void {
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
{ urls: [ `http://localhost:${PORT}/*` ] },
|
{ urls: [ `http://localhost:${PORT}/*` ] },
|
||||||
|
|
@ -73,6 +108,8 @@ function registerHeaderInjector( getToken: () => string | null ): void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Windows ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let loginWindow: BrowserWindow | null = null;
|
let loginWindow: BrowserWindow | null = null;
|
||||||
let currentTokens: Tokens | null = null;
|
let currentTokens: Tokens | null = null;
|
||||||
|
|
@ -94,7 +131,9 @@ function createLoginWindow(): void {
|
||||||
loginWindow.on( 'closed', () => { loginWindow = null; } );
|
loginWindow.on( 'closed', () => { loginWindow = null; } );
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): void {
|
async function createMainWindow(): Promise<void> {
|
||||||
|
await session.defaultSession.clearStorageData( { storages: [ 'cookies' ] } );
|
||||||
|
|
||||||
mainWindow = new BrowserWindow( {
|
mainWindow = new BrowserWindow( {
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 900,
|
height: 900,
|
||||||
|
|
@ -109,7 +148,6 @@ function createMainWindow(): void {
|
||||||
|
|
||||||
const localBase = `http://localhost:${PORT}/`;
|
const localBase = `http://localhost:${PORT}/`;
|
||||||
|
|
||||||
// Keep the main window on localhost — any navigation away means the user needs to re-auth
|
|
||||||
mainWindow.webContents.on( 'will-navigate', ( event, url ) => {
|
mainWindow.webContents.on( 'will-navigate', ( event, url ) => {
|
||||||
if ( !url.startsWith( localBase ) ) {
|
if ( !url.startsWith( localBase ) ) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -123,6 +161,8 @@ function createMainWindow(): void {
|
||||||
mainWindow.on( 'closed', () => { mainWindow = null; } );
|
mainWindow.on( 'closed', () => { mainWindow = null; } );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Server ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function loadEnv(): void {
|
function loadEnv(): void {
|
||||||
const envPath = path.join( __dirname, '..', '..', '.env' );
|
const envPath = path.join( __dirname, '..', '..', '.env' );
|
||||||
try {
|
try {
|
||||||
|
|
@ -141,8 +181,6 @@ function loadEnv(): void {
|
||||||
|
|
||||||
function startExpressServer(): void {
|
function startExpressServer(): void {
|
||||||
loadEnv();
|
loadEnv();
|
||||||
// Tell the server where the project root is so __dirname-relative paths work when compiled.
|
|
||||||
// build/electron/ → up two levels → project root.
|
|
||||||
process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' );
|
process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' );
|
||||||
const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' );
|
const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' );
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
|
@ -150,15 +188,23 @@ function startExpressServer(): void {
|
||||||
startServer( PORT );
|
startServer( PORT );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── App lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.whenReady().then( () => {
|
app.whenReady().then( () => {
|
||||||
registerHeaderInjector( () => currentTokens?.accessToken ?? null );
|
registerHeaderInjector( () => currentTokens?.accessToken ?? null );
|
||||||
|
|
||||||
ipcMain.handle( 'auth:login', async ( _event, email: string, password: string ) => {
|
ipcMain.handle( 'auth:login', async ( _event, email: string, password: string, remember: boolean ) => {
|
||||||
try {
|
try {
|
||||||
const result = await postJson( `${AUTH_HOST}/api/auth/login`, { email, password } ) as Record<string, unknown>;
|
const result = await postJson( `${AUTH_HOST}/api/auth/login`, { email, password } ) as Record<string, unknown>;
|
||||||
if ( result.accessToken && result.refreshToken ) {
|
if ( result.accessToken && result.refreshToken ) {
|
||||||
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
|
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
|
||||||
saveTokens( currentTokens );
|
saveTokens( currentTokens );
|
||||||
|
if ( remember ) {
|
||||||
|
saveLastEmail( email );
|
||||||
|
saveLastPassword( password );
|
||||||
|
} else {
|
||||||
|
clearCredentials();
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
|
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
|
||||||
|
|
@ -167,18 +213,40 @@ app.whenReady().then( () => {
|
||||||
}
|
}
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
ipcMain.handle( 'auth:last-email', () => loadLastEmail() );
|
||||||
|
ipcMain.handle( 'auth:last-password', () => loadLastPassword() );
|
||||||
|
ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } );
|
||||||
|
|
||||||
ipcMain.on( 'auth:login-success', () => {
|
ipcMain.on( 'auth:login-success', () => {
|
||||||
loginWindow?.close();
|
// Create the main window first, close login only after it exists.
|
||||||
createMainWindow();
|
// Closing login before main is ready triggers window-all-closed → app quit.
|
||||||
|
createMainWindow().then( () => loginWindow?.close() );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
startExpressServer();
|
startExpressServer();
|
||||||
|
|
||||||
// Give the server a moment to bind before loading the window
|
setTimeout( async () => {
|
||||||
setTimeout( () => {
|
|
||||||
currentTokens = loadTokens();
|
currentTokens = loadTokens();
|
||||||
if ( currentTokens ) {
|
if ( currentTokens ) {
|
||||||
|
try {
|
||||||
|
const result = await postJson(
|
||||||
|
`${AUTH_HOST}/api/auth/refresh`,
|
||||||
|
{ refreshToken: currentTokens.refreshToken }
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
if ( result.accessToken && result.refreshToken ) {
|
||||||
|
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
|
||||||
|
saveTokens( currentTokens );
|
||||||
createMainWindow();
|
createMainWindow();
|
||||||
|
} else {
|
||||||
|
clearTokens();
|
||||||
|
currentTokens = null;
|
||||||
|
createLoginWindow();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearTokens();
|
||||||
|
currentTokens = null;
|
||||||
|
createLoginWindow();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
createLoginWindow();
|
createLoginWindow();
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +254,7 @@ app.whenReady().then( () => {
|
||||||
|
|
||||||
app.on( 'activate', () => {
|
app.on( 'activate', () => {
|
||||||
if ( BrowserWindow.getAllWindows().length === 0 ) {
|
if ( BrowserWindow.getAllWindows().length === 0 ) {
|
||||||
if ( currentTokens ) createMainWindow();
|
if ( currentTokens ) void createMainWindow();
|
||||||
else createLoginWindow();
|
else createLoginWindow();
|
||||||
}
|
}
|
||||||
} );
|
} );
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld( 'electronAuth', {
|
contextBridge.exposeInMainWorld( 'electronAuth', {
|
||||||
login: ( email: string, password: string ) =>
|
login: ( email: string, password: string, remember: boolean ) =>
|
||||||
ipcRenderer.invoke( 'auth:login', email, password ),
|
ipcRenderer.invoke( 'auth:login', email, password, remember ),
|
||||||
loginSuccess: () =>
|
loginSuccess: () =>
|
||||||
ipcRenderer.send( 'auth:login-success' ),
|
ipcRenderer.send( 'auth:login-success' ),
|
||||||
|
lastEmail: () =>
|
||||||
|
ipcRenderer.invoke( 'auth:last-email' ),
|
||||||
|
lastPassword: () =>
|
||||||
|
ipcRenderer.invoke( 'auth:last-password' ),
|
||||||
|
clearCredentials: () =>
|
||||||
|
ipcRenderer.invoke( 'auth:clear-credentials' ),
|
||||||
} );
|
} );
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 0b06c252a37b7e957be5767b1821c5cb63123918
|
Subproject commit cb2fba6e8b4a7c8ed236113cd40084e100df4021
|
||||||
|
|
@ -18,7 +18,7 @@ project-list-default {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logo: wrapper = inner area (570×240 at 0.35x = 200×84).
|
/* Logo: wrapper = inner area (570×240 at 0.35x = 200×84).
|
||||||
|
|
@ -104,6 +104,7 @@ project-list-default {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
|
margin-top: 4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pld-row-in {
|
@keyframes pld-row-in {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ var NAV_DATA = {
|
||||||
title: 'History',
|
title: 'History',
|
||||||
path: 'history/index.html',
|
path: 'history/index.html',
|
||||||
children: [
|
children: [
|
||||||
|
{ title: 'Friday, 18 July 2026', path: 'history/2026/07-July/18-Friday/index.html' },
|
||||||
{ title: 'Wednesday, 16 July 2026', path: 'history/2026/07-July/16-Wednesday/index.html' },
|
{ title: 'Wednesday, 16 July 2026', path: 'history/2026/07-July/16-Wednesday/index.html' },
|
||||||
{ title: 'Tuesday, 15 July 2026', path: 'history/2026/07-July/15-Tuesday/index.html' },
|
{ title: 'Tuesday, 15 July 2026', path: 'history/2026/07-July/15-Tuesday/index.html' },
|
||||||
{ title: 'Monday, 14 July 2026', path: 'history/2026/07-July/14-Monday/index.html' },
|
{ title: 'Monday, 14 July 2026', path: 'history/2026/07-July/14-Monday/index.html' },
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
<div class="lane-header">MVP</div>
|
<div class="lane-header">MVP</div>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>Local testing solution for the whole rokojori network</task-title>
|
||||||
|
<task-content>
|
||||||
|
A local dev setup that runs all services together: rokojori-auth, styles, tunnel,
|
||||||
|
and roject. Needs hosts file entries for *.local.rokojori.com subdomains so auth
|
||||||
|
cookies flow correctly, per-service .env.local files pointing at each other, and
|
||||||
|
a startup script to launch everything at once. Optionally a local reverse proxy
|
||||||
|
(Caddy) to avoid port numbers in URLs. Currently, the Electron app is the preferred
|
||||||
|
workaround for local testing since it injects auth headers directly.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
<task-item class="blue hide-content">
|
||||||
<task-title>Local Git Repository Integration</task-title>
|
<task-title>Local Git Repository Integration</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,6 @@
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
<div class="lane-header">Critical</div>
|
<div class="lane-header">Critical</div>
|
||||||
|
|
||||||
<task-item class="red hide-content">
|
|
||||||
<task-title>401 Not Handled in Data-Fetching Components</task-title>
|
|
||||||
<task-content>
|
|
||||||
Components that fetch data do not handle 401 responses gracefully — they crash
|
|
||||||
when the API returns an error object instead of an array. Groups and the old
|
|
||||||
projects page have been removed; the current surfaces to check are project-home
|
|
||||||
and its theme components (project-list-default). Should show an appropriate
|
|
||||||
message or redirect to the refresh-session endpoint when a 401 is received.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -87,6 +77,16 @@
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="green hide-content">
|
||||||
|
<task-title>401 Not Handled in Data-Fetching Components</task-title>
|
||||||
|
<task-content>
|
||||||
|
Fixed. editor-shell checks GET /api/auth/me on startup and redirects to '/'
|
||||||
|
on 401. rokojori-auth's page-level middleware no longer redirects on an expired
|
||||||
|
token (was looping to the non-existent refresh-session route) — it calls next()
|
||||||
|
instead, so login is no longer blocked by an expired access token.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,6 @@
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
<div class="lane-header">To Do</div>
|
<div class="lane-header">To Do</div>
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
|
||||||
<task-title>File tree double-click: auto-open or focus existing editor</task-title>
|
|
||||||
<task-content>
|
|
||||||
When a file is double-clicked in the file tree:
|
|
||||||
— If an editor panel that can handle the file type is already open and not pinned,
|
|
||||||
focus that panel's tab and load the file into it.
|
|
||||||
— If no suitable unpinned editor exists, open a new panel of the correct type
|
|
||||||
in the active section before loading the file.
|
|
||||||
Single-click keeps current behaviour (selection only, no open).
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
<task-item class="blue hide-content">
|
||||||
<task-title>Tab-container: split function broken, panel border update unreliable</task-title>
|
<task-title>Tab-container: split function broken, panel border update unreliable</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
|
|
@ -53,14 +41,7 @@
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
|
||||||
<task-title>Mobile: nav bar z-index too low on projects / index view</task-title>
|
|
||||||
<task-content>
|
|
||||||
On mobile the navigation bar on the project list (index) view has insufficient
|
|
||||||
z-index — it is rendered beneath other elements and the logout button and
|
|
||||||
other nav items are not clickable.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
<task-item class="blue hide-content">
|
||||||
<task-title>Code syntax highlighting in rojo-chat (Highlight.js)</task-title>
|
<task-title>Code syntax highlighting in rojo-chat (Highlight.js)</task-title>
|
||||||
|
|
@ -181,6 +162,26 @@
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>Ensure time is not depending on the user's clock</task-title>
|
||||||
|
<task-content>
|
||||||
|
JWT verification on the local server failed because the Windows client clock was
|
||||||
|
~65 minutes ahead of the production auth server clock. Any time-based logic that
|
||||||
|
compares client-side time against server-issued timestamps (JWT exp, token TTL,
|
||||||
|
session validity) is broken when clocks diverge.
|
||||||
|
|
||||||
|
Work to do:
|
||||||
|
— Audit all places where Date.now() / new Date() is used for security or
|
||||||
|
session decisions; replace with server-authoritative time where possible.
|
||||||
|
— On the auth side: use clockTolerance in jwt.verify as a configurable
|
||||||
|
escape hatch (JWT_CLOCK_TOLERANCE env var, already added for local dev).
|
||||||
|
— Write a developer guide covering: why user/client clock cannot be trusted,
|
||||||
|
how to use server time for all authoritative checks, how to diagnose clock
|
||||||
|
skew issues, and the JWT_CLOCK_TOLERANCE workaround for local dev.
|
||||||
|
— Consider syncing advice in the local dev setup docs (future local-dev task).
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
|
|
@ -227,73 +228,30 @@
|
||||||
<div class="lane-header">Done</div>
|
<div class="lane-header">Done</div>
|
||||||
|
|
||||||
<task-item class="green hide-content">
|
<task-item class="green hide-content">
|
||||||
<task-title>rokojori-auth: page-level token refresh middleware</task-title>
|
<task-title>Mobile: nav bar z-index too low on projects / index view</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
Added jwtMiddleware to rokojori-auth index.ts before express.static.
|
Added z-index: 10 to .pld-nav in project-list-default.css.
|
||||||
When a page request arrives with an expired accessToken, it redirects to
|
The nav has position: fixed but lacked a z-index, so stacking contexts
|
||||||
/api/auth/refresh-session?redirect=<url> which rotates both cookies and
|
from position: relative project rows buried it on mobile.
|
||||||
redirects back. Fixes the reload-to-login issue after the 1-hour TTL.
|
Overlays remain above at z-index: 200.
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
<task-item class="green hide-content">
|
<task-item class="green hide-content">
|
||||||
<task-title>Tunnel Agent: token refresh on 401, logout, getToken getter</task-title>
|
<task-title>Electron Roject app: local dev fixes</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
Three improvements to the Electron Tunnel Agent:
|
Several fixes to make the Electron app usable for local development:
|
||||||
— tryRefreshTokens() calls POST /api/auth/refresh on 401, updates currentTokens,
|
— extractToken now checks Authorization Bearer before the accessToken cookie,
|
||||||
retries the original request once; falls through to handleLogout() if refresh fails.
|
so stale browser cookies cannot shadow the injected token.
|
||||||
— handleLogout() clears tokens, stops all agents, closes main window, opens login.
|
— JWT_CLOCK_TOLERANCE env var (seconds) passed to jwt.verify as clockTolerance;
|
||||||
— TunnelAgent config changed from static token: string to getToken: () => string,
|
set to 7200 in .env to absorb clock skew between local and production auth server.
|
||||||
so every WebSocket reconnect picks up the current (possibly refreshed) token
|
— Startup token refresh: on launch with saved tokens, main.ts calls
|
||||||
instead of the expired one baked in at connect time.
|
POST account.rokojori.com/api/auth/refresh before opening the main window;
|
||||||
— Logout button added to window header and tray menu.
|
shows login window if refresh fails.
|
||||||
</task-content>
|
— Quit-on-login fix: createMainWindow() is now async; login-success handler
|
||||||
</task-item>
|
awaits it before closing the login window, preventing window-all-closed → quit.
|
||||||
|
— Credential persistence: email and password saved to userData on successful login;
|
||||||
<task-item class="green hide-content">
|
remember-me checkbox controls whether they are saved; clear button deletes them.
|
||||||
<task-title>rojo-chat-panel: mobile layout fix + animated thinking indicator</task-title>
|
|
||||||
<task-content>
|
|
||||||
CSS: added min-height: 0 to rojo-chat-panel and .rcp-history so the history
|
|
||||||
can shrink in flex on mobile; added overflow: hidden to the panel root.
|
|
||||||
JS: focus listener calls scrollIntoView after 300ms when the input is focused
|
|
||||||
(accommodates keyboard animation on mobile).
|
|
||||||
Replaced static "…" with a cycling animation: ., .., ..., thinking, ., .., ...,
|
|
||||||
imagining (user-customised frames) at 250ms per frame. Interval cleared and
|
|
||||||
bubble wiped the moment the first real response chunk arrives.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
<task-item class="green hide-content">
|
|
||||||
<task-title>Fix session logout after ~1 hour — transparent token refresh</task-title>
|
|
||||||
<task-content>
|
|
||||||
Root cause: jwtMiddleware only redirected to refresh-session for page navigations.
|
|
||||||
API requests with an expired token fell through with req.user = undefined, causing
|
|
||||||
requireAuth to return 401 — no retry, no refresh, silent failure mid-session.
|
|
||||||
|
|
||||||
Fix: when TokenExpiredError hits an API route and a refreshToken cookie is present,
|
|
||||||
jwtMiddleware now calls POST account.rokojori.com/api/auth/refresh server-side,
|
|
||||||
sets the new accessToken and refreshToken cookies on the response, decodes the new
|
|
||||||
JWT into req.user, and calls next(). Completely transparent — no frontend changes.
|
|
||||||
If refresh fails (expired or missing refresh token) the request falls through to
|
|
||||||
requireAuth which returns 401 as before.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
<task-item class="green hide-content">
|
|
||||||
<task-title>CI deploy email notification</task-title>
|
|
||||||
<task-content>
|
|
||||||
EmailService added to Roject (SMTP via Nodemailer, same credentials as rokojori-auth).
|
|
||||||
Startup email sent from startServer() listen callback; deploy email sent from
|
|
||||||
/api/deploy after signature verification passes. reportEmail defined as a static
|
|
||||||
field on EmailService.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
<task-item class="green hide-content">
|
|
||||||
<task-title>Electron Desktop App Shell</task-title>
|
|
||||||
<task-content>
|
|
||||||
Login window, JWT auth via API, Authorization header injection, token persistence,
|
|
||||||
ROJECT_ROOT path fix, ELECTRON_RUN_AS_NODE workaround. Full local-only Electron app working.
|
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Friday, 18 July 2026 — Roject</title>
|
||||||
|
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||||
|
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="date">Friday, 18 July 2026</p>
|
||||||
|
<h1>Session History</h1>
|
||||||
|
<p class="subtitle">CodeMirror syntax highlighting system, smart file-tree open, rokojori-auth login fix. Electron local dev fixes: stale cookie auth, clock tolerance, quit-on-login, credential persistence. Nav z-index fix.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>What we built</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>File tree: single-click smart file open</h3>
|
||||||
|
<p>
|
||||||
|
Clicking a file in the file tree now opens it intelligently rather than always
|
||||||
|
targeting any available panel:
|
||||||
|
</p>
|
||||||
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
||||||
|
<li>If the file is already open in any panel, that panel's tab is focused — no
|
||||||
|
duplicate open, no reload.</li>
|
||||||
|
<li>Otherwise, the next available editor panel of the correct type that is not
|
||||||
|
pinned and not dirty is used; a new panel is created in the active section
|
||||||
|
if none qualifies.</li>
|
||||||
|
<li>Pinned editors (<code>_pinned</code> property on the panel element) are
|
||||||
|
excluded from the available pool entirely.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>CodeMirror vendor syntax modes</h3>
|
||||||
|
<p>
|
||||||
|
Four minified CodeMirror 5 mode files added to <code>source/vendor/</code> and
|
||||||
|
loaded in <code>editor.html</code>:
|
||||||
|
</p>
|
||||||
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
||||||
|
<li><code>cm-mode-clike.min.js</code> — C/C++/Java; used for GLSL and GDShader</li>
|
||||||
|
<li><code>cm-mode-python.min.js</code> — Python; used for GDScript (<code>.gd</code>)</li>
|
||||||
|
<li><code>cm-mode-shell.min.js</code> — shell scripts (<code>.sh</code>)</li>
|
||||||
|
<li><code>cm-mode-yaml.min.js</code> — YAML and YML</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Extension mappings in <code>code-panel</code>'s <code>_resolveMode</code>:
|
||||||
|
<code>.yaml</code>/<code>.yml</code> → <code>yaml</code>,
|
||||||
|
<code>.sh</code> → <code>shell</code>,
|
||||||
|
<code>.gd</code> → <code>python</code>,
|
||||||
|
<code>.glsl</code>/<code>.gdshader</code>/<code>.gdshaderinc</code> → <code>clike</code>,
|
||||||
|
<code>.cs</code> → <code>rokojori-cs</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>CodeMirror lexer mode system — BrowserLexer + CodeMirrorLexerMode</h3>
|
||||||
|
<p>
|
||||||
|
A custom, self-contained browser lexer and CodeMirror mode wrapper built in
|
||||||
|
<code>source/components/code-panel/</code>:
|
||||||
|
</p>
|
||||||
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
||||||
|
<li><code>BrowserLexer.ts</code> — zero external imports. Inlines
|
||||||
|
<code>makeSticky()</code> (adds <code>/y</code> flag to regexes).
|
||||||
|
<code>BrowserMatcher</code> uses sticky regex + <code>lastIndex</code> for
|
||||||
|
positional matching. <code>BrowserLexer</code> holds named mode lists of
|
||||||
|
matchers. Exports a <code>cLikeLexer()</code> factory with matchers for all
|
||||||
|
C-like token types (comments, strings, numbers, operators, keywords,
|
||||||
|
identifiers, etc.).</li>
|
||||||
|
<li><code>CodeMirrorLexerMode.ts</code> — wraps any <code>BrowserLexer</code>
|
||||||
|
into a CodeMirror 5 mode object. Supports multi-line block definitions (start
|
||||||
|
regex → end regex → CSS class; state preserved across lines). Supports named
|
||||||
|
keyword sets: sets of words that override the base CSS class for a given token
|
||||||
|
type (e.g. mapping C# keywords from <code>CWORD</code> → <code>keyword</code>).
|
||||||
|
Keyword sets are mutable at runtime — add/remove/update without recreating
|
||||||
|
the mode. <code>refresh(cm)</code> forces CodeMirror to re-tokenize by
|
||||||
|
re-setting the mode option.</li>
|
||||||
|
<li><code>CSharpMode.ts</code> — creates <code>csharpMode</code> using
|
||||||
|
<code>cLikeLexer()</code> with a multi-line <code>/* ... */</code> comment
|
||||||
|
block and a keyword set of ~70 C# keywords. Registered in CodeMirror as
|
||||||
|
<code>'rokojori-cs'</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
The browser-only design (no <code>library-ts</code> dependency) avoids the
|
||||||
|
<code>moduleResolution: "bundler"</code> / <code>ts-node</code> conflict:
|
||||||
|
library-ts compiles without <code>.js</code> extensions (works for
|
||||||
|
<code>ts-node</code>), while browser ES modules require explicit extensions.
|
||||||
|
Keeping the lexer self-contained in <code>code-panel/</code> eliminates the
|
||||||
|
tension entirely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>rokojori-auth: remove broken refresh-session redirect</h3>
|
||||||
|
<p>
|
||||||
|
The page-level middleware in <code>rokojori-auth/source/server/index.ts</code>
|
||||||
|
was redirecting expired <code>accessToken</code> requests to
|
||||||
|
<code>/api/auth/refresh-session?redirect=...</code> — a route that no longer
|
||||||
|
exists. This blocked login entirely (redirect loop on first visit after token
|
||||||
|
expiry). Fixed by replacing the entire error branch with <code>next()</code>:
|
||||||
|
the middleware now passes through on any token error. Transparent refresh for
|
||||||
|
Roject API calls is handled server-side by Roject's own
|
||||||
|
<code>jwtMiddleware</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Key decisions</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
<strong>Self-contained BrowserLexer instead of reusing library-ts CLikeLexer.</strong>
|
||||||
|
Importing from library-ts pulled in extensionless relative imports that break the
|
||||||
|
browser ES module loader (<code>NS_ERROR_CORRUPTED_CONTENT</code>). Adding
|
||||||
|
<code>.js</code> extensions to library-ts imports broke <code>ts-node</code>
|
||||||
|
(CommonJS cannot remap <code>.js</code> → <code>.ts</code>). The cleanest fix
|
||||||
|
was a purpose-built, dependency-free browser lexer duplicating only what the
|
||||||
|
code editor needs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
<strong>Single-click open, not double-click.</strong> The board task said
|
||||||
|
double-click, but single-click is more natural for an IDE file tree (matches
|
||||||
|
VS Code, JetBrains). The smart-targeting logic (focus existing, skip pinned)
|
||||||
|
makes single-click safe — it never disrupts an intentionally pinned panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Session 2 — Electron local dev fixes</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Mobile: nav z-index fix</h3>
|
||||||
|
<p>
|
||||||
|
<code>.pld-nav</code> in <code>project-list-default.css</code> has
|
||||||
|
<code>position: fixed</code> but no <code>z-index</code>. Stacking contexts
|
||||||
|
created by <code>position: relative</code> project rows on mobile buried the nav
|
||||||
|
underneath them. Fixed with <code>z-index: 10</code>. Overlays remain above at
|
||||||
|
<code>z-index: 200</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Bearer-before-cookie in <code>extractToken</code></h3>
|
||||||
|
<p>
|
||||||
|
The root cause of Electron auth failures: <code>extractToken()</code> in
|
||||||
|
<code>source/auth-connector/source/server/auth.ts</code> was checking the
|
||||||
|
<code>accessToken</code> cookie before the <code>Authorization</code> header.
|
||||||
|
Electron's Chromium session had a stale <code>accessToken</code> cookie that took
|
||||||
|
priority over the fresh Bearer token injected via
|
||||||
|
<code>session.defaultSession.webRequest.onBeforeSendHeaders</code>. Fixed by
|
||||||
|
reversing the check order: Bearer header wins, cookie is the fallback.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Additionally, <code>createMainWindow()</code> now calls
|
||||||
|
<code>session.defaultSession.clearStorageData({ storages: ['cookies'] })</code>
|
||||||
|
before creating the window, preventing the stale cookie from accumulating across
|
||||||
|
Electron restarts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>JWT clock skew — <code>JWT_CLOCK_TOLERANCE</code></h3>
|
||||||
|
<p>
|
||||||
|
Fresh tokens issued by the production auth server (1 h TTL) appeared expired
|
||||||
|
immediately on the Windows dev machine because the local clock was ~65 minutes
|
||||||
|
ahead of the server. Every <code>jwt.verify</code> call returned
|
||||||
|
<code>TokenExpiredError</code> seconds after login.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Fix: <code>jwt.verify</code> accepts a <code>clockTolerance</code> option. An env
|
||||||
|
var <code>JWT_CLOCK_TOLERANCE</code> (integer, seconds; default 0) is now read and
|
||||||
|
passed as <code>clockTolerance</code> when non-zero. Set to <code>7200</code> in
|
||||||
|
<code>.env</code> for local development. The underlying audit task (time must never
|
||||||
|
depend on the user's clock) is on the board.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Electron startup token refresh + quit-on-login fix</h3>
|
||||||
|
<p>
|
||||||
|
Two issues fixed in <code>electron/main.ts</code>:
|
||||||
|
</p>
|
||||||
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
||||||
|
<li><strong>Startup refresh:</strong> on launch with saved tokens, the app now
|
||||||
|
calls <code>POST account.rokojori.com/api/auth/refresh</code> before opening
|
||||||
|
the main window. Fresh tokens are saved; if refresh fails the login window is
|
||||||
|
shown instead. This prevents using expired access tokens on startup.</li>
|
||||||
|
<li><strong>Quit-on-login race:</strong> <code>createMainWindow()</code> was made
|
||||||
|
<code>async</code> (to await the cookie clear), but <code>loginWindow?.close()</code>
|
||||||
|
was called before awaiting it. Zero open windows → <code>window-all-closed</code>
|
||||||
|
→ <code>app.quit()</code>. Fixed by:
|
||||||
|
<code>createMainWindow().then(() => loginWindow?.close())</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Electron login: credential persistence + remember-me</h3>
|
||||||
|
<p>
|
||||||
|
The login window (<code>electron/login.html</code>) was extended:
|
||||||
|
</p>
|
||||||
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
||||||
|
<li><strong>Remember me</strong> checkbox (checked by default): when checked,
|
||||||
|
email and password are saved to <code>userData/last-email.txt</code> and
|
||||||
|
<code>userData/last-password.txt</code> on successful login; when unchecked, any
|
||||||
|
saved files are deleted.</li>
|
||||||
|
<li><strong>Clear saved</strong> button: calls
|
||||||
|
<code>auth:clear-credentials</code> IPC, wipes the fields, and disables itself.
|
||||||
|
Hidden when no credentials are saved.</li>
|
||||||
|
<li>On load, both fields are pre-filled from saved values; focus goes to the Sign
|
||||||
|
in button if both are filled, the password field if only email is saved, or the
|
||||||
|
email field if nothing is saved.</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
IPC surface added to <code>electron/preload.ts</code>:
|
||||||
|
<code>lastEmail()</code>, <code>lastPassword()</code>, <code>clearCredentials()</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Roject — session history
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>var NAV_ROOT = '../../../../';</script>
|
||||||
|
<script src="../../../../_assets_/nav-data.js"></script>
|
||||||
|
<script src="../../../../_assets_/nav.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
<section>
|
<section>
|
||||||
<h2>2026 — July</h2>
|
<h2>2026 — July</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><a href="2026/07-July/18-Friday/index.html">Friday, 18 July 2026</a></h3>
|
||||||
|
<p>CodeMirror syntax highlighting: vendor modes (clike, python, shell, yaml) + custom C# mode built on BrowserLexer + CodeMirrorLexerMode with dynamic keyword sets. File tree single-click smart open (focus existing, skip pinned). rokojori-auth login fix: removed broken refresh-session redirect. Electron local dev fixes: Bearer-before-cookie token extraction, JWT_CLOCK_TOLERANCE clock skew escape hatch, startup token refresh, quit-on-login race fix, credential persistence with remember-me. Mobile nav z-index fix.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3>
|
<h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3>
|
||||||
<p>rokojori-tunnel: Phase 1 relay server, Electron Tunnel Agent, production deployment, streaming protocol, Roject browse-tunnels UI, LLM chat via tunnel, chunk animation. Token refresh fixes across three services: rokojori-auth page-level middleware, Roject transparent API refresh, Tunnel Agent 401 refresh + logout + getToken getter. rojo-chat-panel mobile layout and animated thinking indicator.</p>
|
<p>rokojori-tunnel: Phase 1 relay server, Electron Tunnel Agent, production deployment, streaming protocol, Roject browse-tunnels UI, LLM chat via tunnel, chunk animation. Token refresh fixes across three services: rokojori-auth page-level middleware, Roject transparent API refresh, Tunnel Agent 401 refresh + logout + getToken getter. rojo-chat-panel mobile layout and animated thinking indicator.</p>
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,35 @@
|
||||||
token rotation, roles, and account deletion.
|
token rotation, roles, and account deletion.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-top:0.75rem">
|
<p style="margin-top:0.75rem">
|
||||||
<strong>Token refresh — two layers:</strong>
|
<strong>Token refresh:</strong>
|
||||||
rokojori-auth has a page-level middleware (before <code>express.static</code>) that
|
rokojori-auth's page-level middleware (before <code>express.static</code>)
|
||||||
redirects any page request carrying an expired <code>accessToken</code> to
|
verifies the <code>accessToken</code> cookie but calls <code>next()</code> on
|
||||||
<code>/api/auth/refresh-session</code>, which rotates both cookies and redirects back.
|
any error — no redirect on expiry. Transparent refresh for API calls is handled
|
||||||
Roject's <code>jwtMiddleware</code> handles mid-session API calls: when a
|
by Roject's <code>jwtMiddleware</code>: when a <code>TokenExpiredError</code>
|
||||||
<code>TokenExpiredError</code> hits an <code>/api/</code> route and a
|
hits an <code>/api/</code> route and a <code>refreshToken</code> cookie is present,
|
||||||
<code>refreshToken</code> cookie is present, it calls
|
it calls <code>POST AUTH_INTERNAL_HOST/api/auth/refresh</code> server-side, sets
|
||||||
<code>POST AUTH_INTERNAL_HOST/api/auth/refresh</code> server-side, sets the new
|
the new cookies on the response, decodes the new JWT into <code>req.user</code>,
|
||||||
cookies on the response, and continues transparently. <code>AUTH_INTERNAL_HOST</code>
|
and continues transparently. If refresh fails, the request falls through to
|
||||||
|
<code>requireAuth</code> which returns 401. <code>AUTH_INTERNAL_HOST</code>
|
||||||
defaults to <code>AUTH_HOST</code>; set it to <code>http://localhost:3001</code>
|
defaults to <code>AUTH_HOST</code>; set it to <code>http://localhost:3001</code>
|
||||||
in production to bypass nginx for the server-to-server call.
|
in production to bypass nginx. The <code>editor-shell</code> checks
|
||||||
|
<code>GET /api/auth/me</code> on startup and redirects to <code>/</code> on 401.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Token extraction order (<code>extractToken</code> in <code>auth.ts</code>):</strong>
|
||||||
|
Bearer header is checked before the <code>accessToken</code> cookie. This means an
|
||||||
|
explicit <code>Authorization: Bearer ...</code> header always wins — critical for
|
||||||
|
Electron (which injects tokens via <code>onBeforeSendHeaders</code>), API clients,
|
||||||
|
and any context where a stale browser cookie might otherwise shadow a fresh token.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Clock skew — <code>JWT_CLOCK_TOLERANCE</code>:</strong>
|
||||||
|
<code>jwt.verify</code> accepts a <code>clockTolerance</code> option (seconds).
|
||||||
|
The env var <code>JWT_CLOCK_TOLERANCE</code> (default 0 / unset) is read as an
|
||||||
|
integer and passed as <code>clockTolerance</code> when non-zero. Set to
|
||||||
|
<code>7200</code> in <code>.env</code> for local development to absorb clock skew
|
||||||
|
between the Windows dev machine and the production auth server. Never set this in
|
||||||
|
production — if you need it there, fix the clock instead.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -155,9 +173,49 @@
|
||||||
A <code>FileEditorRegistry</code> routes files to the correct panel by extension:
|
A <code>FileEditorRegistry</code> routes files to the correct panel by extension:
|
||||||
HTML → <code>html-editor-panel</code> (iframe, contenteditable, MutationObserver,
|
HTML → <code>html-editor-panel</code> (iframe, contenteditable, MutationObserver,
|
||||||
undo/redo, Ctrl+S save); all other text formats → <code>code-panel</code>
|
undo/redo, Ctrl+S save); all other text formats → <code>code-panel</code>
|
||||||
(CodeMirror 5, syntax highlighting, dark theme). An optional project-level
|
(CodeMirror 5, syntax highlighting, dark theme). Godot file types
|
||||||
<code>workspace/editor/file-editors.json</code> overrides the defaults.
|
(<code>.gd</code>, <code>.gdshader</code>, <code>.gdshaderinc</code>,
|
||||||
|
<code>.tscn</code>, <code>.tres</code>, <code>.res</code>) are pre-registered.
|
||||||
|
An optional project-level <code>workspace/editor/file-editors.json</code>
|
||||||
|
overrides the defaults.
|
||||||
</p>
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Clicking a file in the file tree opens it with smart panel targeting: if the file
|
||||||
|
is already open in any panel, that panel's tab is focused. Otherwise, the next
|
||||||
|
available unpinned non-dirty editor of the correct type is used; a new panel is
|
||||||
|
created in the active section if none qualifies. Pinned panels are never overwritten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>CodeMirror syntax highlighting</h3>
|
||||||
|
<p>
|
||||||
|
Vendor modes bundled: <code>clike</code> (C/C++/Java/GLSL/GDShader),
|
||||||
|
<code>python</code> (GDScript), <code>shell</code>, <code>yaml</code>.
|
||||||
|
Extension → mode mappings in <code>_resolveMode</code>:
|
||||||
|
<code>.yaml</code>/<code>.yml</code> → <code>yaml</code>,
|
||||||
|
<code>.sh</code> → <code>shell</code>,
|
||||||
|
<code>.gd</code> → <code>python</code>,
|
||||||
|
<code>.glsl</code>/<code>.gdshader</code>/<code>.gdshaderinc</code> → <code>clike</code>,
|
||||||
|
<code>.cs</code> → <code>rokojori-cs</code>.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Custom C# mode</strong> is built on a browser-only lexer stack in
|
||||||
|
<code>source/components/code-panel/</code> (no <code>library-ts</code> dependency —
|
||||||
|
avoids the <code>ts-node</code> / browser-extension import conflict):
|
||||||
|
</p>
|
||||||
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
||||||
|
<li><code>BrowserLexer.ts</code> — zero imports. <code>BrowserMatcher</code> uses
|
||||||
|
sticky regexes (<code>/y</code> flag + <code>lastIndex</code>) for positional
|
||||||
|
matching. <code>cLikeLexer()</code> factory returns a full C-like token set.</li>
|
||||||
|
<li><code>CodeMirrorLexerMode.ts</code> — wraps a <code>BrowserLexer</code> into a
|
||||||
|
CodeMirror 5 mode. Supports multi-line blocks (start/end regex + CSS class, state
|
||||||
|
persisted across lines) and named keyword sets that override the base CSS class for
|
||||||
|
matching token types at runtime. <code>refresh(cm)</code> forces re-tokenization.</li>
|
||||||
|
<li><code>CSharpMode.ts</code> — <code>csharpMode</code> registered as
|
||||||
|
<code>'rokojori-cs'</code>; ~70 C# keywords mapped from <code>CWORD</code>
|
||||||
|
→ <code>keyword</code>.</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue