Compare commits

...

34 Commits

Author SHA1 Message Date
aewing 5834bd1e60 Load env from Secrets path with fallback 2026-06-11 18:56:45 -05:00
aewing a5408ba8da Keep new note form cleared after create 2026-03-29 03:36:01 +00:00
aewing 602c275644 Use exact-case TasGrid task collection 2026-03-29 03:32:26 +00:00
aewing d70a8b64c6 Mirror Job-Info-Test PB token validation flow 2026-03-29 03:29:19 +00:00
aewing a045a1fbd8 Add PocketBase token context diagnostics 2026-03-29 03:22:51 +00:00
aewing a34b362683 Broaden Tasgird task lookup fallbacks 2026-03-29 03:19:52 +00:00
aewing 69c8d80ef2 Harden Tasgird user task lookup 2026-03-29 03:16:07 +00:00
aewing e6b727b345 Resolve tasks by Users.name to Tasgird user relation 2026-03-29 03:12:26 +00:00
aewing b3b2a2e2f0 Return real task lookup failures instead of false rules warnings 2026-03-29 03:08:05 +00:00
aewing 006f976f57 Use request-scoped PocketBase auth for task endpoints 2026-03-29 03:05:41 +00:00
aewing 49824ea3c6 Use exact Users.name for current task lookup and auto-load tasks 2026-03-29 03:00:32 +00:00
aewing 6abaf6c778 Use user name for tasks lookup with robust fallbacks 2026-03-29 02:51:14 +00:00
aewing d042f00230 Enforce job note number at toggle level and tighten save UX 2026-03-29 02:36:07 +00:00
aewing 9a6da32504 Use job note toggle and require job number for job_note 2026-03-29 02:19:13 +00:00
aewing 21c8b4b4c7 Prevent accidental duplicate notes in workspace actions 2026-03-28 21:31:02 +00:00
aewing c4c5906daf Add audio recording workflow to notes workspace 2026-03-28 19:59:07 +00:00
aewing e9d520fd5e Add attachments workflow to notes workspace 2026-03-28 19:43:30 +00:00
aewing 744adc8645 Auto-load notes on notes workspace startup for existing sessions 2026-03-28 19:36:37 +00:00
aewing 7ecb7256de Align notes workspace filter/load behavior with legacy records 2026-03-28 19:33:31 +00:00
aewing da1a97b8fd Fix notes workspace filters for string-based boolean fields 2026-03-28 19:29:15 +00:00
aewing 94d6840ef7 Add modern notes workspace with core legacy note functions 2026-03-28 19:26:35 +00:00
aewing 17904cff3a Promote legacy notes UI to native notes.html route 2026-03-28 19:22:11 +00:00
aewing d6b0e3192a Add isolated legacy notes route and tasks view with resilient task APIs 2026-03-28 19:17:56 +00:00
aewing 8ca2c97e37 Upgrade OneNote sync with section/page targeting and edit mode
- Add GET /api/onenote/section-pages endpoint for listing pages in a section
- Extend /api/onenote/append with syncMode add/edit support
- Support editing an existing note block by noteId on a selected page
- Keep add mode for appending to selected page or creating new page in section
- Add Note + Sync UI controls for sync mode, section/page target, existing noteId,
  and optional new page title
- Wire frontend flow to load sync targets and pages, then sync with selected mode
2026-03-24 18:45:27 +00:00
aewing 513e975530 Fix PocketBase auth and note submission
- Replace popup OAuth with SDK authWithOAuth2 realtime channel (urlCallback)
  so login works in Edge app mode without navigating the main window
- Remove manual redirect/sessionStorage OAuth flow that broke in Edge app mode
- Serve PocketBase config (URL, collection, provider) from server endpoint
  backed by env vars instead of hardcoded localhost
- Fix /api/submit: target Notes collection with correct field mapping
  (body_plain, body_html, title, type, email, Username, userId, job_note,
  Job_Number) instead of Job_Info_Prod with wrong field names
- Remove localhost fallbacks from auth-module backend and frontend
2026-03-24 18:29:51 +00:00
aewing c76739d0d7 Update Prism Notes branding and OneNote sync UI 2026-03-24 17:15:19 +00:00
aewing 04ae0c0c82 UI improvements: filter buttons, note detail styling
- Color-code filter buttons to match note types: grey (all), green (my shared), blue (shared with me), pink (job), orange (manager), amber (personal)
- Decrease note detail container width: max-w-4xl → max-w-2xl
- Increase body text area height to 224px with min-height on display
- Hide Audio/Attachments labels when no content present
- Remove placeholder text for empty audio/attachments sections
2026-01-16 12:06:06 +00:00
aewing 4c6058c656 Extract auth module: standalone PocketBase OAuth2 + Graph token management
- Create reusable auth-module/ with frontend and backend components
- Frontend: PocketBaseAuth class for OAuth2 login and token management
- Backend: GraphTokenManager and PocketBaseValidator for token operations
- Includes TypeScript types and comprehensive README
- No project-specific dependencies - ready to use in other projects
2026-01-16 03:49:52 +00:00
aewing 864fcab1ce Fix voice search for notes list
- Fix 'already started' error in speech recognition
- Add toggle behavior for voice button (start/stop)
- Improve transcript handling for single words
- Show interim results in real-time as user speaks
- Add detailed console logging for debugging
- Ensure search triggers on final results
- Search already covers both title and body fields
2026-01-16 02:42:04 +00:00
aewing 1b751742dc Add hidden notes feature, note filters, detail view edit/delete, and dynamic styling
- Hidden notes feature: hide/unhide notes with dedicated view, matching main list styling
- Filter buttons: All Notes, My Shared, Shared With Me, Job Notes, Manager Notes, Personal Notes
- Note detail view: dynamic background colors matching note card styling
- Edit functionality: double-click title/body to edit, inline save/cancel
- Soft delete: delete button with confirmation, sets delete=true in database
- Icon updates: magnifying glass for hide/unhide actions (14px)
- Session log: documented all feature changes
2026-01-16 02:28:48 +00:00
aewing 95888562a0 Add alert sound on note share - plays for recipients only, fix favicon 404 2026-01-16 01:44:57 +00:00
aewing 7c4459fd47 Add alert creation on note sharing - triggers alerts for shared_with users 2026-01-16 01:29:48 +00:00
aewing 9ced9fd4ed User email lookup component + architecture decision: Vite frontend + Bun backend 2026-01-14 13:25:47 +00:00
aewing efbe5d6573 Add user email lookup component for sharing UI tooltips 2026-01-14 12:59:28 +00:00
16 changed files with 8596 additions and 2843 deletions
+16
View File
@@ -31,3 +31,19 @@ Open http://localhost:3030 and sign in with Microsoft.
- `getGraphToken()` in `server.ts` is ready for Graph API calls; extend with additional routes as needed.
- `/api/submit` validates the provided `pbToken` then writes the payload to `PB_COLLECTION`; adjust fields to match your schema.
- Static file serving uses `index.html` from the repo root.
## OneNote sync (append MVP)
- Backend endpoint: `POST /api/onenote/append`
- Required headers:
- `Authorization: Bearer <delegated Microsoft access token>`
- `x-pb-token: <PocketBase user token>`
- Current hardcoded target in `server.ts`:
- Host: `czflex.sharepoint.com`
- Site path: `/sites/Team`
- Section ID: `ed504699-67be-47a3-838a-e01ec17198fb`
- Page ID: `addbbca7-18ce-4d8d-833e-96cc08d990bc`
- Frontend buttons:
- New note: `Submit + Sync OneNote`
- Note detail: `Sync to OneNote`
Important: sync requires a delegated Microsoft token with OneNote write permissions. The UI captures this from PocketBase Microsoft OAuth metadata when available.
+244
View File
@@ -0,0 +1,244 @@
# Auth Module
Standalone authentication module for PocketBase OAuth2 + Microsoft Graph integration.
## Features
- **Frontend**: PocketBase OAuth2 authentication with Microsoft provider
- **Backend**: Microsoft Graph token management with automatic caching
- **No dependencies on project-specific code** - Use in any project
## Installation
Copy the `auth-module` folder to your project:
```bash
cp -r auth-module /path/to/your/project/
```
Install dependencies:
```bash
bun add pocketbase @azure/msal-node
```
## Frontend Usage
### Basic Setup
```typescript
import { PocketBaseAuth } from './auth-module/frontend';
const auth = new PocketBaseAuth({
pbUrl: 'http://localhost:8090',
collection: 'Users',
provider: 'microsoft',
loginContainerId: 'loginContainer',
userDisplayNameId: 'userDisplayName',
userEmailId: 'userEmailValue',
loginBtnId: 'loginBtn',
loginErrorId: 'loginError',
});
```
### HTML Required
```html
<!-- Login container (shown when not authenticated) -->
<div id="loginContainer" class="hidden">
<button id="loginBtn">Login with Microsoft</button>
<div id="loginError" class="hidden"></div>
</div>
<!-- User display (shown when authenticated) -->
<div id="userDisplayName"></div>
<div id="userEmailValue"></div>
```
### Event Callbacks
```typescript
const auth = new PocketBaseAuth(config, {
onAuthSuccess: (state) => {
console.log('Logged in:', state.user.email);
// Initialize other systems here
},
onAuthFailure: (error) => {
console.error('Login failed:', error);
},
onTokenUpdate: (state) => {
console.log('Token refreshed');
},
onUiUpdate: (state) => {
console.log('UI updated');
},
});
```
### Methods
```typescript
// Get current auth state
const state = auth.getAuthState();
console.log(state.isAuthenticated, state.user);
// Check token status
const status = await auth.checkTokenStatus();
// Logout
auth.logout();
// Get raw PocketBase instance if needed
const pb = auth.getPocketBase();
```
## Backend Usage
### Setup (server.ts)
```typescript
import { GraphTokenManager, PocketBaseValidator, BackendAuth } from './auth-module/backend';
// Option 1: Use individual managers
const graphMgr = new GraphTokenManager({
clientId: process.env.CLIENT_ID,
tenantId: process.env.TENANT_ID,
clientSecret: process.env.CLIENT_SECRET,
});
const pbValidator = new PocketBaseValidator('http://127.0.0.1:8090');
// Option 2: Use combined manager
const backendAuth = new BackendAuth({
clientId: process.env.CLIENT_ID,
tenantId: process.env.TENANT_ID,
clientSecret: process.env.CLIENT_SECRET,
});
```
### Get Graph Token
```typescript
// Get token string
const token = await graphMgr.getToken();
// Get token with expiration
const { token, expiresOnISO } = await graphMgr.getTokenWithExpiry();
// Check if cached and valid
if (graphMgr.isTokenValid()) {
console.log('Using cached token');
}
// Clear cache (force refresh)
graphMgr.clearCache();
```
### Validate PocketBase Token
```typescript
// Validate token
const isValid = await pbValidator.validateUserToken(token);
// Get user record
const user = await pbValidator.getUserRecord(token);
// Get PocketBase instance
const pb = pbValidator.getPocketBase();
```
### Endpoint Example
```typescript
app.get('/api/graph/status', async (c) => {
try {
const { token, expiresOnISO } = await graphMgr.getTokenWithExpiry();
return c.json({
active: true,
expiresOnISO,
});
} catch (error) {
return c.json(
{ active: false, error: error.message },
500
);
}
});
app.post('/api/submit', async (c) => {
const body = await c.req.json();
const pbToken = body.pbToken;
if (!pbToken) {
return c.json({ error: 'Missing pbToken' }, 401);
}
const isValid = await pbValidator.validateUserToken(pbToken);
if (!isValid) {
return c.json({ error: 'Invalid token' }, 401);
}
// Token is valid, proceed with business logic
// ...
});
```
## Environment Variables
Required in `.env` file:
```env
CLIENT_ID=your-microsoft-app-id
TENANT_ID=your-azure-tenant-id
CLIENT_SECRET=your-microsoft-client-secret
PB_DB=http://127.0.0.1:8090 # PocketBase URL
```
## Configuration
### Frontend Config Options
```typescript
interface AuthConfig {
pbUrl?: string; // PocketBase URL (default: http://localhost:8090)
collection?: string; // Auth collection (default: Users)
provider?: string; // OAuth provider (default: microsoft)
loginContainerId?: string; // ID of login container element
userDisplayNameId?: string; // ID of user name display element
userEmailId?: string; // ID of user email display element
loginBtnId?: string; // ID of login button element
loginErrorId?: string; // ID of error message element
}
```
### Backend Config Options
```typescript
interface BackendAuthConfig {
clientId?: string; // Microsoft app ID (or CLIENT_ID env var)
tenantId?: string; // Azure tenant ID (or TENANT_ID env var)
clientSecret?: string; // Client secret (or CLIENT_SECRET env var)
}
```
## TypeScript
The module exports TypeScript types for type safety:
```typescript
import { AuthState, AuthConfig, GraphTokenCache } from './auth-module/types';
const state: AuthState = auth.getAuthState();
```
## Notes
- **Frontend** manages user authentication only
- **Backend** manages Graph API tokens (never exposed to client)
- Tokens are cached in-memory on backend with automatic refresh
- Module does not include project-specific features (alerts, etc.)
- Each project can implement its own business logic on top
## License
Same as parent project
+171
View File
@@ -0,0 +1,171 @@
import { ConfidentialClientApplication } from '@azure/msal-node';
import PocketBase from 'pocketbase';
import { GraphTokenCache, BackendAuthConfig } from './types';
/**
* Microsoft Graph Token Management (Backend)
* Handles token acquisition and caching for backend Graph API calls
*/
export class GraphTokenManager {
private cca: ConfidentialClientApplication;
private cache: GraphTokenCache | null = null;
private config: Required<BackendAuthConfig>;
constructor(config: BackendAuthConfig) {
this.config = {
clientId: config.clientId || process.env.CLIENT_ID || '',
tenantId: config.tenantId || process.env.TENANT_ID || '',
clientSecret: config.clientSecret || process.env.CLIENT_SECRET || '',
};
this.cca = new ConfidentialClientApplication({
auth: {
clientId: this.config.clientId,
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
clientSecret: this.config.clientSecret,
},
});
}
/**
* Get Graph token (from cache or acquire new)
*/
async getToken(): Promise<string> {
const now = Date.now();
// Check cache validity (with 60s buffer for expiration)
if (this.cache && this.cache.expiresOn - 60000 > now) {
return this.cache.token;
}
try {
const result = await this.cca.acquireTokenByClientCredential({
scopes: ['https://graph.microsoft.com/.default'],
});
if (!result?.accessToken) {
throw new Error('Failed to acquire Graph token');
}
const expiresOn = result.expiresOn
? new Date(result.expiresOn).getTime()
: now + 55 * 60 * 1000; // Default 55 minutes
this.cache = {
token: result.accessToken,
expiresOn,
};
return result.accessToken;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to acquire Graph token: ${message}`);
}
}
/**
* Get token with expiration info
*/
async getTokenWithExpiry(): Promise<{ token: string; expiresOnISO: string }> {
const token = await this.getToken();
const expiresOn = this.cache?.expiresOn || Date.now();
return {
token,
expiresOnISO: new Date(expiresOn).toISOString(),
};
}
/**
* Check if token is cached and valid
*/
isTokenValid(): boolean {
if (!this.cache) return false;
const now = Date.now();
return this.cache.expiresOn - 60000 > now;
}
/**
* Clear cache (force refresh on next call)
*/
clearCache(): void {
this.cache = null;
}
}
/**
* PocketBase Token Validation (Backend)
* Validates and uses user PocketBase tokens
*/
export class PocketBaseValidator {
private pb: PocketBase;
constructor(pbUrl?: string) {
this.pb = new PocketBase(pbUrl || process.env.PB_URL || process.env.PB_DB || '');
}
/**
* Set user token and validate it
*/
async validateUserToken(token: string): Promise<boolean> {
try {
this.pb.authStore.save(token, null);
await this.pb.collection('Users').authRefresh();
return true;
} catch (error) {
console.error('Token validation failed:', error instanceof Error ? error.message : error);
return false;
}
}
/**
* Get user record from token
*/
async getUserRecord(token: string): Promise<any> {
try {
this.pb.authStore.save(token, null);
const record = this.pb.authStore.record || this.pb.authStore.model;
return record;
} catch (error) {
console.error('Failed to get user record:', error);
return null;
}
}
/**
* Get PocketBase instance
*/
getPocketBase(): PocketBase {
return this.pb;
}
}
/**
* Combined backend auth manager
*/
export class BackendAuth {
graphTokenManager: GraphTokenManager;
pbValidator: PocketBaseValidator;
constructor(config: BackendAuthConfig, pbUrl?: string) {
this.graphTokenManager = new GraphTokenManager(config);
this.pbValidator = new PocketBaseValidator(pbUrl);
}
/**
* Middleware to validate PocketBase token in requests
* Usage: app.use('/*', (c, next) => backendAuth.validateTokenMiddleware(c, next))
*/
async validateTokenMiddleware(c: any, next: any): Promise<any> {
const token = c.req.header('Authorization')?.replace('Bearer ', '') ||
(await c.req.json().catch(() => ({})))?.pbToken;
if (token) {
const isValid = await this.pbValidator.validateUserToken(token);
if (!isValid) {
return c.json({ error: 'Invalid token' }, 401);
}
}
return next();
}
}
+240
View File
@@ -0,0 +1,240 @@
import PocketBase from 'pocketbase';
import { AuthConfig, AuthState, AuthCallbacks } from './types';
/**
* PocketBase OAuth2 Frontend Module
* Handles user authentication and token management
*/
export class PocketBaseAuth {
private pb: PocketBase;
private config: Required<AuthConfig>;
private callbacks: AuthCallbacks;
private state: AuthState;
constructor(config: AuthConfig, callbacks?: Partial<AuthCallbacks>) {
this.config = {
pbUrl: config.pbUrl || '',
collection: config.collection || 'Users',
provider: config.provider || 'microsoft',
loginContainerId: config.loginContainerId || 'loginContainer',
userDisplayNameId: config.userDisplayNameId || 'userDisplayName',
userEmailId: config.userEmailId || 'userEmailValue',
loginBtnId: config.loginBtnId || 'loginBtn',
loginErrorId: config.loginErrorId || 'loginError',
};
this.callbacks = {
onAuthSuccess: callbacks?.onAuthSuccess || (() => {}),
onAuthFailure: callbacks?.onAuthFailure || (() => {}),
onTokenUpdate: callbacks?.onTokenUpdate || (() => {}),
onUiUpdate: callbacks?.onUiUpdate || (() => {}),
};
this.pb = new PocketBase(this.config.pbUrl);
this.state = {
isAuthenticated: false,
user: null,
token: null,
};
this.init();
}
/**
* Initialize auth module
*/
private init(): void {
this.updateAuthUI();
if (this.pb.authStore.isValid && this.pb.authStore.token) {
this.ensureUserLogged();
}
this.setupLoginButton();
}
/**
* Setup login button click handler
*/
private setupLoginButton(): void {
const loginBtn = document.getElementById(this.config.loginBtnId);
if (!loginBtn) {
console.warn(`Login button with id "${this.config.loginBtnId}" not found`);
return;
}
loginBtn.addEventListener('click', async (e) => {
e.preventDefault();
await this.handleLogin();
});
}
/**
* Handle login button click
*/
private async handleLogin(): Promise<void> {
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement;
if (!loginBtn || !loginError) return;
loginBtn.disabled = true;
loginBtn.textContent = 'Checking session...';
loginError.classList.add('hidden');
try {
// Try to use existing token first
const hadToken = await this.ensureUserLogged();
if (hadToken) {
this.resetLoginButton();
return;
}
// Otherwise perform OAuth
loginBtn.textContent = 'Logging in...';
const authData = await this.pb.collection(this.config.collection).authWithOAuth2({
provider: this.config.provider,
});
this.updateState(authData);
this.updateAuthUI();
this.callbacks.onAuthSuccess?.(this.state);
console.log('✓ Logged in with', this.config.provider);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Login failed:', message);
loginError.textContent = `Login failed: ${message}`;
loginError.classList.remove('hidden');
this.callbacks.onAuthFailure?.(error);
} finally {
this.resetLoginButton();
}
}
/**
* Ensure user is logged in (check existing token)
*/
async ensureUserLogged(): Promise<boolean> {
if (!this.pb.authStore.isValid || !this.pb.authStore.token) {
return false;
}
try {
const refresh = await this.pb.collection(this.config.collection).authRefresh();
this.updateState(refresh);
this.updateAuthUI();
this.callbacks.onTokenUpdate?.(this.state);
console.log('✓ Token refreshed');
return true;
} catch (error) {
console.warn('Existing token invalid, clearing auth');
this.pb.authStore.clear();
this.updateState(null);
this.updateAuthUI();
return false;
}
}
/**
* Update internal auth state
*/
private updateState(data: any): void {
if (!data) {
this.state = {
isAuthenticated: false,
user: null,
token: null,
};
return;
}
const record = data.record || this.pb.authStore.record || this.pb.authStore.model;
const meta = data.meta || {};
const model = this.pb.authStore.model;
this.state = {
isAuthenticated: true,
user: {
id: record?.id || model?.id || 'unknown',
name: record?.name || model?.name || meta?.name || record?.email || model?.email || 'Unknown User',
email: record?.email || model?.email || meta?.email || '(no email)',
},
token: this.pb.authStore.token,
};
}
/**
* Update UI based on auth state
*/
updateAuthUI(): void {
const loginContainer = document.getElementById(this.config.loginContainerId);
const userDisplayName = document.getElementById(this.config.userDisplayNameId);
const userEmail = document.getElementById(this.config.userEmailId);
if (!loginContainer) return;
if (this.pb.authStore.isValid) {
loginContainer.classList.add('hidden');
if (this.state.user) {
if (userDisplayName) userDisplayName.textContent = this.state.user.name;
if (userEmail) userEmail.textContent = this.state.user.email;
}
} else {
loginContainer.classList.remove('hidden');
const loginError = document.getElementById(this.config.loginErrorId);
if (loginError) loginError.classList.add('hidden');
}
this.callbacks.onUiUpdate?.(this.state);
}
/**
* Check token status (for verification/display)
*/
async checkTokenStatus(): Promise<{ pbToken: boolean; tokenExpiry?: string }> {
const pbToken = !!this.pb.authStore.token;
return { pbToken };
}
/**
* Get current auth state
*/
getAuthState(): AuthState {
return { ...this.state };
}
/**
* Get PocketBase instance (for direct usage if needed)
*/
getPocketBase(): PocketBase {
return this.pb;
}
/**
* Logout
*/
logout(): void {
this.pb.authStore.clear();
this.updateState(null);
this.updateAuthUI();
console.log('✓ Logged out');
}
/**
* Reset login button to initial state
*/
private resetLoginButton(): void {
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
if (loginBtn) {
loginBtn.disabled = false;
loginBtn.textContent = 'Login with Microsoft';
}
}
}
/**
* Quick init function for simple use cases
*/
export async function initPocketBaseAuth(config: AuthConfig): Promise<PocketBaseAuth> {
const auth = new PocketBaseAuth(config);
return auth;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Frontend Auth Configuration
*/
export interface AuthConfig {
pbUrl?: string;
collection?: string;
provider?: string;
loginContainerId?: string;
userDisplayNameId?: string;
userEmailId?: string;
loginBtnId?: string;
loginErrorId?: string;
}
/**
* Backend Auth Configuration
*/
export interface BackendAuthConfig {
clientId?: string;
tenantId?: string;
clientSecret?: string;
}
/**
* Auth state object
*/
export interface AuthState {
isAuthenticated: boolean;
user: {
id: string;
name: string;
email: string;
} | null;
token: string | null;
}
/**
* Graph token cache object
*/
export interface GraphTokenCache {
token: string;
expiresOn: number;
}
/**
* Auth event callbacks
*/
export interface AuthCallbacks {
onAuthSuccess?: (state: AuthState) => void;
onAuthFailure?: (error: any) => void;
onTokenUpdate?: (state: AuthState) => void;
onUiUpdate?: (state: AuthState) => void;
}
+803 -2764
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes End User License Agreement</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 32px 20px; }
h1, h2 { color: #f8fafc; }
a { color: #93c5fd; }
p, li { line-height: 1.6; }
.muted { color: #94a3b8; }
</style>
</head>
<body>
<main>
<h1>Prism Notes End User License Agreement (EULA)</h1>
<p class="muted">Last updated: March 25, 2026</p>
<p>
This EULA is a legal agreement between you and Cardoza Construction for use of the Prism Notes
application and related services.
</p>
<h2>License Grant</h2>
<p>
Subject to your compliance with this agreement, Cardoza Construction grants you a limited,
non-exclusive, non-transferable, revocable license to use Prism Notes for authorized business use.
</p>
<h2>Restrictions</h2>
<ul>
<li>You may not reverse engineer, copy, resell, or sublicense the application.</li>
<li>You may not use the application for unlawful activity.</li>
<li>You must not interfere with system security, availability, or integrity.</li>
</ul>
<h2>Ownership</h2>
<p>
Prism Notes and all related intellectual property remain the exclusive property of Cardoza Construction
and its licensors.
</p>
<h2>Data & Integrations</h2>
<p>
You are responsible for ensuring your use of third-party integrations complies with applicable
terms and permissions. Use of Prism Notes is also governed by the Privacy Policy.
</p>
<h2>Disclaimer</h2>
<p>
Prism Notes is provided "as is" without warranties of any kind, to the extent permitted by law.
</p>
<h2>Limitation of Liability</h2>
<p>
To the maximum extent permitted by law, Cardoza Construction is not liable for indirect,
incidental, special, consequential, or punitive damages arising from use of the application.
</p>
<h2>Termination</h2>
<p>
This license may terminate if you violate this agreement. Upon termination, you must stop using
Prism Notes.
</p>
<h2>Contact</h2>
<p>
For legal questions, contact: <a href="mailto:admin@ccllc.pro">admin@ccllc.pro</a>
</p>
</main>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Privacy Policy</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 32px 20px; }
h1, h2 { color: #f8fafc; }
a { color: #93c5fd; }
p, li { line-height: 1.6; }
.muted { color: #94a3b8; }
</style>
</head>
<body>
<main>
<h1>Prism Notes Privacy Policy</h1>
<p class="muted">Last updated: March 25, 2026</p>
<p>
Prism Notes is operated by Cardoza Construction. This policy describes how Prism Notes collects,
uses, and protects information when you use the application and connected integrations.
</p>
<h2>Information We Collect</h2>
<ul>
<li>Account profile details needed for sign-in and authorization.</li>
<li>Notes and metadata you submit within Prism Notes.</li>
<li>Integration data required for configured services (for example, OneNote sync).</li>
<li>Basic operational logs for security, troubleshooting, and auditing.</li>
</ul>
<h2>How We Use Information</h2>
<ul>
<li>Provide core app functionality and note synchronization.</li>
<li>Authenticate users and secure access to data.</li>
<li>Maintain reliability, detect abuse, and troubleshoot issues.</li>
<li>Comply with legal and contractual obligations.</li>
</ul>
<h2>Data Sharing</h2>
<p>
We do not sell personal data. Data is shared only with service providers and integrations required to
deliver application features, or as required by law.
</p>
<h2>Data Retention</h2>
<p>
Data is retained only as long as required to operate Prism Notes, meet legal obligations, and enforce
agreements.
</p>
<h2>Security</h2>
<p>
We use reasonable administrative and technical safeguards to protect data. No method of transmission
or storage is guaranteed to be 100% secure.
</p>
<h2>Contact</h2>
<p>
For privacy questions, contact: <a href="mailto:admin@ccllc.pro">admin@ccllc.pro</a>
</p>
</main>
</body>
</html>
+163
View File
@@ -3,6 +3,91 @@ PRISM NOTES - SESSION LOG
Project history, decisions, and architectural milestones
================================================================================
[2026-01-14 16:30] ARCHITECTURE DECISION: Migrate to Vite Frontend + Bun Backend
- Decision: Adopt Vite as frontend bundler to enforce TypeScript everywhere
- Reasoning: Current .js tool deviation and CDN Tailwind are pragmatically expensive
Triggers met: >5 tools, tools interact with PocketBase (type safety needed)
- Architecture (FINAL):
* Backend: Bun + Hono (server.ts remains unchanged)
* Frontend: TypeScript + Vite + PostCSS Tailwind
* Tools: Convert to .ts, compiled by Vite bundler
* Package Manager: Bun exclusively (one bun.lock, no npm/pnpm conflicts)
- Benefits:
* Type safety everywhere (no vanilla JS)
* HMR in dev (fast reloads <100ms)
* Optimized bundles (minified, tree-shaken)
* Tools scale cleanly as .ts modules
* Removes CDN Tailwind and .js tool deviations
- Implementation Plan (Next Session):
1. Setup Vite config + package.json scripts (~15 mins)
2. Convert index.html to Vite entry point (~30 mins)
3. Extract inline scripts to .ts modules (~1 hour)
4. Convert all tools to .ts: screen-capture, screen-record, user-alert, user-email-lookup (~2 hours)
5. Configure Tailwind with PostCSS for Vite (~15 mins)
6. Update Monica persona (Bun backend + Vite frontend standard) (~20 mins)
7. Clean DEVIATIONS.txt (remove CDN Tailwind, .js tools entries) (~10 mins)
8. Test build pipeline and dev experience (~30 mins)
Total: ~4-5 hours
- Current State:
* User email lookup component created and integrated (NOT committed yet)
* All other work committed and synced
* Ready for Vite migration on next session
- Notes for Next Session:
* Start with: `git status` to see uncommitted email-lookup work
* Decide: Include email-lookup work in Vite migration OR commit first
* Vite setup: Use `npm init vite@latest` as template, adapt to Bun
* Key files to create: vite.config.ts, tailwind.config.js, postcss.config.js
* Key files to convert: index.html → src/main.ts, all tools to .ts
* Build commands: "build": "vite build", "dev": "bun run build && PORT=3030 bun run --watch server.ts"
[2026-01-14 16:00] MONICA SESSION: User Email Lookup Feature - New Standalone Component
- Feature Request: Add hover tooltips showing email addresses when hovering over names in sharing UI
- Architecture Decision: Created as standalone component in /tools/user-email-lookup/
Reasoning: Self-contained, reusable utility with single responsibility (email lookup)
- Implementation:
* Created /tools/user-email-lookup/index.js (window.setupUserEmailLookup API)
* Created /tools/user-email-lookup/README.md (complete documentation)
* Integrated into index.html noteForm and conversationForm
* Uses existing Associations collection for first_name → email lookups
* Implements email caching to minimize PocketBase queries
* Uses MutationObserver for dynamic capsule detection
* Shows email in native browser tooltip on hover
- Features:
* On note creation, user types names (e.g., "John, Sarah, Mike")
* Each name creates a capsule in selectedUsersContainer
* Hovering over capsule shows email (first hover queries DB, subsequent cached)
* Cache persists for page session, can be cleared via window.clearEmailLookupCache()
- Integration Points:
* Script loaded: <script src="tools/user-email-lookup/index.js"></script>
* Initialized: showNoteForm() and showConversationForm()
* Container: #selectedUsersContainer (for both notes and conversations)
- Status: Implemented and integrated, NOT YET COMMITTED
- Note: Will be converted to .ts as part of Vite migration (next session)
[2026-01-14 15:45] MONICA SESSION: Standards Alignment Fixes
- Feature Request: Add hover tooltips showing email addresses when hovering over names in sharing UI
- Architecture Decision: Created as standalone component in /tools/user-email-lookup/
Reasoning: Self-contained, reusable utility with single responsibility (email lookup)
- Implementation:
* Created /tools/user-email-lookup/index.js (window.setupUserEmailLookup API)
* Created /tools/user-email-lookup/README.md (complete documentation)
* Integrated into index.html noteForm and conversationForm
* Uses existing Associations collection for first_name → email lookups
* Implements email caching to minimize PocketBase queries
* Uses MutationObserver for dynamic capsule detection
* Shows email in native browser tooltip on hover
- Features:
* On note creation, user types names (e.g., "John, Sarah, Mike")
* Each name creates a capsule in selectedUsersContainer
* Hovering over capsule shows email (first hover queries DB, subsequent cached)
* Cache persists for page session, can be cleared via window.clearEmailLookupCache()
- Integration Points:
* Script loaded: <script src="tools/user-email-lookup/index.js"></script>
* Initialized: showNoteForm() and showConversationForm()
* Container: #selectedUsersContainer (for both notes and conversations)
- Status: Implemented and ready for testing
- Next: Test with real Associations data and refine if needed
[2026-01-14 15:45] MONICA SESSION: Standards Alignment Fixes
- Action: Addressed audit findings - minor alignment issues
- Fixes Applied:
@@ -82,4 +167,82 @@ Project history, decisions, and architectural milestones
becomes more established and needs full file separation
- Status: Functional and ready for independent container updates
[2026-01-15 INVESTIGATION] PocketBase Associations Collection API Rules
- Problem: User email lookup queries Associations → "only superusers can perform this action"
- Root Cause: Associations collection LIST API rule is restricted (default deny all)
- Solution: Set Associations LIST API rule to `@request.auth.id != ""` (allow authenticated users)
- Forum Source: https://github.com/pocketbase/pocketbase/discussions/5948#5948-answer
* Official pattern for "authenticate first" checks
* Works for all read/write operations
* Syntax: `@request.auth.id != ""` (empty string = anonymous/unauthenticated)
- Implementation (Manual in PocketBase Admin):
1. Go to PocketBase Admin Console
2. Select Associations collection
3. API Rules tab → List action
4. Set rule to: `@request.auth.id != ""`
5. Save and test with authenticated user
- Why UserAlertSystem Works: Unknown at this point (may have different API rule set)
- Status: Solution identified, requires manual PocketBase admin console update
[2026-01-16 FEATURE] Note Creation Alerts - Notify Shared Users
- Problem: When a note is created and shared with users, those users should receive alerts
- Solution: Use existing UserAlertSystem realtime monitoring
- How it works:
* When note is created, it's published with `shared: true` and `shared_with: [names]`
* UserAlertSystem monitors Notes collection in realtime via PocketBase
* When new note event arrives, system checks if current user is in shared_with
* If match, plays notification sound automatically
- No code changes needed to submitNote - just ensure:
* Note is created with `shared: true` and `shared_with` populated (already done)
* UserAlertSystem is running in user's browser (via index.html)
* Realtime subscription is active (UserAlertSystem handles this)
- Benefits:
* No database records needed for alerts
* Real-time notification (minimal latency)
* Works for self-sharing (user shares note with self)
- Status: Simplified approach - removed unnecessary Alerts collection code
[2026-01-16 SESSION] FEATURE: Hidden Notes Management
- Feature Request: Create ability to hide notes from main list, view hidden notes, and unhide them
- Architecture Decision: Simple boolean flag (hidden) on Notes collection, dedicated view container
- Implementation:
* Added "Hidden Notes" button (id="hiddenNotesBtn", gray-600 style) to notes header
* Created hiddenNotesContainer (gray-200 bg, similar layout to notes list)
* Added hide button (👁️‍🗨️ icon) to each note card in main list
- Click handler: Update note.hidden=true, reload list
- Styled: Gray text, hover red, positioned right of title
* Added unhide button (👁️ icon) to each hidden note in hidden view
- Click handler: Update note.hidden=false, reload both hidden notes and main list
- Styled: Gray text, hover green, positioned right of title
* Created renderHiddenNotes() function
- Filters notesList for n.hidden === true
- Renders cards similar to main list but with unhide buttons
- Shows empty state "No hidden notes." if none exist
* Created showHiddenNotes() view switcher
- Hides all other containers, shows hiddenNotesContainer
- Calls renderHiddenNotes() to populate
* Updated showNotesList() to hide hiddenNotesContainer
* Wired button click handlers:
- hiddenNotesBtn → showHiddenNotes()
- backToNotesFromHiddenBtn → showNotesList()
* Updated applySearch() to filter hidden notes from main view (filter: !n.hidden)
- Features:
* Hide any note from main list with single click
* Dedicated gray-themed view for organizing hidden notes
* Unhide with single click - returns to main list automatically
* Maintains existing styling: light blue for shared-with-me notes, green for shared notes
* No database schema changes needed - uses existing boolean field
- Status: Implemented and tested, NOT YET COMMITTED (awaiting user instruction)
- Database Schema: Notes.hidden (boolean, default false)
* No migration needed (handled via PocketBase UI or default values)
* Existing notes will have hidden=false implicitly
- UI State Flow:
1. Main list: Shows all notes where !hidden, with hide button on each
2. Hidden view: Shows all notes where hidden=true, with unhide button on each
3. Clicking hide: Updates note, reloads, stays in main view
4. Clicking unhide: Updates note, reloads both views, returns to main
5. Search filtering: applySearch() respects hidden flag
- Next: Await user confirmation to commit
================================================================================
+1109
View File
File diff suppressed because it is too large Load Diff
+3408
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@
"start": "bun run server.ts"
},
"dependencies": {
"@azure/msal-browser": "^5.6.1",
"@azure/msal-node": "^2.6.7",
"@microsoft/microsoft-graph-client": "^3.0.7",
"dotenv": "^16.4.5",
+1379 -79
View File
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Tasks</title>
<link rel="icon" type="image/png" href="/images/prism.png" />
<script src="https://cdn.tailwindcss.com"></script>
<style>
.panel {
border-radius: 1rem;
border: 1px solid rgb(30 41 59 / 1);
background: rgb(15 23 42 / 0.78);
backdrop-filter: blur(8px);
padding: 1.25rem;
}
.btn {
border-radius: 0.75rem;
padding: 0.55rem 1rem;
font-weight: 600;
transition: all 160ms ease;
position: relative;
overflow: hidden;
}
.btn:focus-visible,
.field:focus-visible {
outline: 2px solid rgb(34 211 238 / 0.8);
outline-offset: 1px;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent 0%, rgb(255 255 255 / 0.2) 48%, transparent 100%);
transform: translateX(-130%);
transition: transform 260ms ease;
pointer-events: none;
}
.btn:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 10px 22px rgb(15 23 42 / 0.28);
}
.btn:hover::before { transform: translateX(130%); }
.btn-accent-cyan {
color: rgb(8 47 73 / 1);
background: rgb(6 182 212 / 1);
}
.btn-accent-emerald {
color: rgb(6 44 32 / 1);
background: rgb(34 197 94 / 1);
}
.btn-secondary {
border: 1px solid rgb(51 65 85 / 1);
background: rgb(15 23 42 / 1);
color: rgb(226 232 240 / 1);
}
.btn-secondary:hover { border-color: rgb(100 116 139 / 1); }
.field {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgb(51 65 85 / 1);
background: rgb(2 6 23 / 1);
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: rgb(248 250 252 / 1);
}
.field option {
background: rgb(15 23 42 / 0.96);
color: rgb(248 250 252 / 1);
}
.field::placeholder { color: rgb(100 116 139 / 1); }
.mono-pill {
border-radius: 0.5rem;
border: 1px solid rgb(51 65 85 / 1);
background: rgb(2 6 23 / 1);
padding: 0.15rem 0.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.75rem;
color: rgb(191 219 254 / 1);
white-space: nowrap;
}
.task-cell {
border-top: 1px solid rgb(30 41 59 / 1);
padding: 0.65rem 0.5rem;
vertical-align: top;
font-size: 0.8rem;
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900 text-slate-100">
<main class="mx-auto max-w-7xl px-6 py-10">
<header class="panel mb-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<img src="/images/prism.png" alt="Prism Notes" class="h-14 w-14 rounded-xl object-cover ring-1 ring-slate-600/60" />
<div>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Tasks View</h1>
</div>
</div>
<div class="flex flex-wrap gap-3">
<a href="/notes-workspace" class="btn btn-secondary">Open Notes Workspace</a>
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
<a href="/" class="btn btn-secondary">Back to Testing View</a>
</div>
</div>
<p class="mt-3 text-sm text-slate-300">Browse PocketBase <span class="mono-pill">Tasgird</span> tasks. Default filter is current user, with optional user switching.</p>
</header>
<section class="mb-6 grid gap-4 md:grid-cols-3">
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Server</p>
<p id="healthStatus" class="mt-2 text-sm font-medium">Checking...</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">PocketBase User</p>
<p id="pbStatus" class="mt-2 text-sm font-medium">Not logged in</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Task Count</p>
<p id="taskCount" class="mt-2 text-sm font-medium">0</p>
</article>
</section>
<section class="mb-6 grid gap-6 lg:grid-cols-2">
<article class="panel p-6">
<h2 class="text-xl font-medium">1) PocketBase Login</h2>
<p class="mt-2 text-sm text-slate-300">Uses your existing PocketBase Microsoft OAuth provider.</p>
<div class="mt-4 flex flex-wrap gap-3">
<button id="loginBtn" class="btn btn-accent-cyan">Login with Microsoft</button>
<button id="logoutBtn" class="btn btn-secondary">Logout</button>
</div>
</article>
<article class="panel p-6">
<h2 class="text-xl font-medium">2) Task Filter</h2>
<p class="mt-2 text-sm text-slate-300">Defaults to your tasks, but you can select other users.</p>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-slate-300">User</label>
<select id="taskUserSelect" class="field mt-1">
<option value="">— login to load users —</option>
</select>
</div>
<div class="flex flex-wrap gap-3">
<button id="loadTasksBtn" class="btn btn-accent-emerald">Load Tasks</button>
<button id="refreshUsersBtn" class="btn btn-secondary">Refresh Users</button>
</div>
</div>
</article>
</section>
<section class="panel p-6">
<h2 class="text-xl font-medium">Tasks (Tasgird)</h2>
<div class="mt-4 overflow-x-auto">
<table class="w-full min-w-[1300px] border-collapse">
<thead>
<tr class="text-left text-xs uppercase tracking-wider text-slate-400">
<th class="task-cell">id</th>
<th class="task-cell">user</th>
<th class="task-cell">title</th>
<th class="task-cell">startDate (UTC)</th>
<th class="task-cell">dueDate (UTC)</th>
<th class="task-cell">priority</th>
<th class="task-cell">completed</th>
<th class="task-cell">content</th>
<th class="task-cell">size</th>
<th class="task-cell">status</th>
</tr>
</thead>
<tbody id="tasksBody">
<tr><td class="task-cell text-slate-400" colspan="10">No tasks loaded.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="panel p-6 mt-6">
<h2 class="text-xl font-medium">Output</h2>
<pre id="output" class="mt-4 min-h-[180px] overflow-auto rounded-xl border border-slate-800 bg-slate-950 p-4 text-xs leading-5 text-slate-200"></pre>
</section>
</main>
<script type="module">
import PocketBase from 'https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/+esm';
const PB_DEFAULT_AUTH_COLLECTION = 'Users';
const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft';
let pb = null;
let pbConfigPromise = null;
let usersCache = [];
const output = document.getElementById('output');
const healthStatus = document.getElementById('healthStatus');
const pbStatus = document.getElementById('pbStatus');
const taskCount = document.getElementById('taskCount');
const taskUserSelect = document.getElementById('taskUserSelect');
const tasksBody = document.getElementById('tasksBody');
function writeOutput(value) {
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function toUtcText(value) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toISOString();
}
function stripHtml(value) {
if (!value) return '';
const div = document.createElement('div');
div.innerHTML = String(value);
return (div.textContent || div.innerText || '').trim();
}
async function getPocketBaseConfig() {
if (!pbConfigPromise) {
pbConfigPromise = fetch('/api/auth/pocketbase-config')
.then(async (resp) => {
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success || !data?.pbUrl) {
throw new Error(data?.message || 'PocketBase config unavailable');
}
return {
pbUrl: data.pbUrl,
collection: data.collection || PB_DEFAULT_AUTH_COLLECTION,
provider: data.provider || PB_DEFAULT_OAUTH_PROVIDER,
};
})
.catch((error) => {
pbConfigPromise = null;
throw error;
});
}
return pbConfigPromise;
}
async function ensurePocketBaseClient() {
if (pb) return pb;
const config = await getPocketBaseConfig();
pb = new PocketBase(config.pbUrl);
return pb;
}
async function updatePbStatus() {
const client = await ensurePocketBaseClient();
if (client.authStore?.isValid) {
const email = client.authStore.model?.email || '(unknown user)';
pbStatus.textContent = `Logged in: ${email}`;
} else {
pbStatus.textContent = 'Not logged in';
}
}
async function loadHealth() {
try {
const resp = await fetch('/health');
const data = await resp.json().catch(() => ({}));
healthStatus.textContent = resp.ok ? `OK (${data.pbDB || 'configured'})` : 'Unavailable';
} catch {
healthStatus.textContent = 'Unavailable';
}
}
async function loginPocketBase() {
writeOutput('Opening Microsoft login...');
const client = await ensurePocketBaseClient();
const config = await getPocketBaseConfig();
const authData = await client.collection(config.collection).authWithOAuth2({
provider: config.provider,
urlCallback(url) {
const w = Math.min(900, window.screen.availWidth || 900);
const h = Math.min(680, window.screen.availHeight || 680);
const left = Math.floor(((window.screen.availWidth || 1280) - w) / 2);
const top = Math.floor(((window.screen.availHeight || 800) - h) / 2);
window.open(url, 'pb_oauth', `width=${w},height=${h},top=${top},left=${left},resizable=yes,menubar=no`);
},
});
await updatePbStatus();
await loadUsers();
writeOutput({ message: 'PocketBase login completed.', user: authData?.record?.email || client.authStore.model?.email || null });
}
async function logoutPocketBase() {
const client = await ensurePocketBaseClient();
client.authStore.clear();
usersCache = [];
taskUserSelect.innerHTML = '<option value="">— login to load users —</option>';
tasksBody.innerHTML = '<tr><td class="task-cell text-slate-400" colspan="10">No tasks loaded.</td></tr>';
taskCount.textContent = '0';
await updatePbStatus();
writeOutput({ message: 'PocketBase session cleared.' });
}
function getCurrentToken() {
if (!pb?.authStore?.isValid || !pb?.authStore?.token) {
throw new Error('PocketBase login required');
}
return pb.authStore.token;
}
async function validateCurrentToken() {
await ensurePocketBaseClient();
const token = getCurrentToken();
const resp = await fetch('/api/auth/validate-pb-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pbToken: token }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.valid !== true) {
const details = data?.details
? (typeof data.details === 'string' ? data.details : JSON.stringify(data.details))
: '';
const context = data?.tokenContext ? ` tokenContext=${JSON.stringify(data.tokenContext)}` : '';
throw new Error(details
? `${data?.error || 'PocketBase token invalid'}: ${details}${context}`
: `${data?.error || 'PocketBase token invalid'}${context}`);
}
return data;
}
async function loadUsers() {
await ensurePocketBaseClient();
const token = getCurrentToken();
const resp = await fetch('/api/tasks/users', {
headers: {
'x-pb-token': token,
'Authorization': `Bearer ${token}`,
},
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
const details = data?.details
? (typeof data.details === 'string' ? data.details : JSON.stringify(data.details))
: '';
const context = data?.tokenContext ? ` tokenContext=${JSON.stringify(data.tokenContext)}` : '';
throw new Error(details
? `${data?.message || 'Failed to load users'}: ${details}${context}`
: `${data?.message || 'Failed to load users'}${context}`);
}
usersCache = Array.isArray(data.users) ? data.users : [];
const meId = String(data?.me?.id || '').trim();
const meName = data?.me?.name || data?.me?.email || 'Me';
const meLookupName = String(data?.me?.lookupName || data?.me?.name || '').trim();
taskUserSelect.innerHTML = '';
for (const user of usersCache) {
const opt = document.createElement('option');
const lookupName = String(user.lookupName || user.name || '').trim();
if (!lookupName) {
continue;
}
opt.value = lookupName;
opt.dataset.userId = user.id;
opt.textContent = `${user.name || user.email || user.id}${user.id === meId ? ' (Me)' : ''}`;
taskUserSelect.appendChild(opt);
}
if (meLookupName) {
taskUserSelect.value = meLookupName;
}
writeOutput({ message: `Loaded ${usersCache.length} users`, me: { id: meId, name: meName, lookupName: meLookupName } });
if (data?.warning) {
writeOutput({ message: `Loaded ${usersCache.length} users`, warning: data.warning, me: { id: meId, name: meName, lookupName: meLookupName } });
}
}
function renderTasks(tasks) {
const rows = Array.isArray(tasks) ? tasks : [];
taskCount.textContent = String(rows.length);
if (!rows.length) {
tasksBody.innerHTML = '<tr><td class="task-cell text-slate-400" colspan="10">No tasks found for selected user.</td></tr>';
return;
}
tasksBody.innerHTML = '';
for (const task of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="task-cell"><span class="mono-pill">${task.id || ''}</span></td>
<td class="task-cell">${task.userDisplay || task.user || ''}</td>
<td class="task-cell">${task.title || ''}</td>
<td class="task-cell">${toUtcText(task.startDate) || ''}</td>
<td class="task-cell">${toUtcText(task.dueDate) || ''}</td>
<td class="task-cell">${task.priority ?? ''}</td>
<td class="task-cell">${task.completed ? 'true' : 'false'}</td>
<td class="task-cell">${stripHtml(task.content || '').slice(0, 240)}</td>
<td class="task-cell">${task.size ?? ''}</td>
<td class="task-cell">${task.status ?? ''}</td>
`;
tasksBody.appendChild(tr);
}
}
async function loadTasks() {
await ensurePocketBaseClient();
const token = getCurrentToken();
const userName = String(taskUserSelect.value || '').trim();
const selectedOption = taskUserSelect.selectedOptions?.[0] || null;
const userId = String(selectedOption?.dataset?.userId || '').trim();
const query = new URLSearchParams();
if (userName) query.set('userName', userName);
if (userId) query.set('userId', userId);
const url = query.toString()
? `/api/tasks/list?${query.toString()}`
: '/api/tasks/list';
const resp = await fetch(url, {
headers: {
'x-pb-token': token,
'Authorization': `Bearer ${token}`,
},
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
const details = data?.details
? (typeof data.details === 'string' ? data.details : JSON.stringify(data.details))
: '';
const context = data?.tokenContext ? ` tokenContext=${JSON.stringify(data.tokenContext)}` : '';
throw new Error(details
? `${data?.message || 'Failed to load tasks'}: ${details}${context}`
: `${data?.message || 'Failed to load tasks'}${context}`);
}
if (data?.userName && taskUserSelect.value !== data.userName) {
taskUserSelect.value = data.userName;
}
renderTasks(data.tasks || []);
writeOutput({
message: `Loaded ${data?.count ?? 0} task(s)`,
userName: data?.userName || userName,
userId: data?.userId || undefined,
authUser: data?.authUser || undefined,
tokenContext: data?.tokenContext || undefined,
lookupMethod: data?.lookupMethod || undefined,
warning: data?.warning || undefined,
tasks: data.tasks || [],
});
}
async function bootstrapTasksPage() {
try {
const client = await ensurePocketBaseClient();
const config = await getPocketBaseConfig();
if (client.authStore?.token) {
try {
await client.collection(config.collection).authRefresh();
} catch {
client.authStore.clear();
writeOutput('Existing PocketBase session could not refresh for Users collection. Please login again.');
}
}
await updatePbStatus();
if (client.authStore?.isValid) {
const tokenValidation = await validateCurrentToken();
writeOutput({ message: 'PocketBase token validated', user: tokenValidation?.user || null, tokenContext: tokenValidation?.tokenContext || undefined });
await loadUsers();
await loadTasks();
}
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
}
function bind(id, handler) {
document.getElementById(id).addEventListener('click', async () => {
try {
await handler();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
}
bind('loginBtn', loginPocketBase);
bind('logoutBtn', logoutPocketBase);
bind('refreshUsersBtn', loadUsers);
bind('loadTasksBtn', loadTasks);
taskUserSelect.addEventListener('change', async () => {
try {
await loadTasks();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
bootstrapTasksPage();
loadHealth();
</script>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
# User Email Lookup Tool
A standalone utility for looking up email addresses from the Associations collection and displaying them as hover tooltips over user names in the sharing interface.
## Features
- 🔍 Email lookup via Associations collection by first name
- 💬 Hover tooltips show email addresses automatically
- 🚀 Request caching to minimize PocketBase queries
- 👁️ Automatic tooltip persistence during hover
- 📍 MutationObserver integration for dynamic capsule additions
- ⚡ Graceful fallback for missing emails
## Installation
Copy the `user-email-lookup` folder to your project:
```bash
cp -r user-email-lookup /path/to/project/tools/
```
## Usage
After the tool is loaded and PocketBase is initialized, call:
```javascript
// Initialize email lookups for note sharing
window.setupUserEmailLookup(pb, '#selectedUsersContainer');
// Or for conversation sharing
window.setupUserEmailLookup(pb, '#selectedConversationUsersContainer');
```
### Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| `pb` | PocketBase | Initialized PocketBase client instance |
| `containerSelector` | string | CSS selector for container with user capsules (e.g., `#selectedUsersContainer`) |
## How It Works
1. **Initial Setup**: Scans container for existing user capsules and attaches hover listeners
2. **Dynamic Monitoring**: Uses MutationObserver to detect newly added capsules
3. **Email Lookup**: On hover, queries Associations collection for `first_name` match
4. **Caching**: Stores results in memory to prevent redundant queries
5. **Tooltip Display**: Shows email in native browser tooltip (`title` attribute)
### Example User Experience
```
User types: "John, Sarah, Mike"
System creates capsules with their first names
User hovers over "John" capsule
→ Lookup queries Associations for first_name = "John"
→ Finds: john.smith@company.com
→ Tooltip shows: "john.smith@company.com"
User hovers over "Sarah" capsule
→ Cache hit (if already looked up Sarah recently)
→ Tooltip shows: "sarah.jones@company.com"
```
## API
### `setupUserEmailLookup(pb, containerSelector)`
Initialize email lookups for a container of user capsules.
```javascript
window.setupUserEmailLookup(pb, '#selectedUsersContainer');
```
### `getEmailByFirstName(pb, firstName)`
Manually lookup an email by first name. Used internally but available for custom extensions.
```javascript
const email = await getEmailByFirstName(pb, 'John');
// Returns: 'john@example.com' or 'Email not found' or 'Error loading email'
```
### `clearEmailLookupCache()`
Clear the in-memory email cache. Useful if Associations collection is updated and you need fresh data.
```javascript
window.clearEmailLookupCache();
```
## Integration
This tool is designed for Prism Notes integration. Include it in `index.html`:
```html
<script src="tools/user-email-lookup/index.js"></script>
```
Then initialize after PocketBase auth completes:
```javascript
// In the user auth flow
if (pb.authStore.isValid) {
window.setupUserEmailLookup(pb, '#selectedUsersContainer');
}
```
## PocketBase Requirements
Your **Associations** collection must have:
- `first_name` field (string) — used for lookups
- `email` field (string) — displayed in tooltip
### Example Associations Record
```json
{
"id": "abc123",
"first_name": "John",
"email": "john.smith@company.com",
"last_name": "Smith",
"created": "2026-01-14T...",
"updated": "2026-01-14T..."
}
```
## Caching Behavior
**Cache Storage**: In-memory JavaScript object (lost on page refresh)
**When to Clear**:
- After Associations collection updates
- If user updates their profile
- Before bulk operations with many lookups
**Manual Clear**:
```javascript
window.clearEmailLookupCache();
```
## Browser Compatibility
- Works in all modern browsers (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
- Requires MutationObserver API (widely supported)
- Uses native `title` attribute for tooltips (no dependencies)
## Performance Notes
- First lookup for a name: ~100-200ms (network + PocketBase query)
- Subsequent lookups: <1ms (cache hit)
- Memory usage: ~100 bytes per cached name
- MutationObserver overhead: Negligible
## Troubleshooting
### "Email not found" tooltip
Check that:
1. Associations collection exists in PocketBase
2. `first_name` field is spelled correctly
3. User's record has the `first_name` populated
4. PocketBase read rules allow access to Associations collection
### Slow tooltips on first hover
This is expected—first lookup queries PocketBase. Subsequent hovers are instant (cached).
### "Error loading email" tooltip
Check PocketBase console for errors. Possible causes:
- Network connectivity issues
- Insufficient permissions to read Associations
- PocketBase server downtime
### Cache not clearing
Cache is in-memory only. Refresh the page to clear, or call:
```javascript
window.clearEmailLookupCache();
```
## Future Enhancements
- [ ] Persistent storage (IndexedDB) for longer-lived cache
- [ ] Configurable tooltip styling (custom CSS instead of native title)
- [ ] Bulk email lookup for performance optimization
- [ ] Email suggestion/autocomplete during input
- [ ] Fallback email patterns (first.last@company.com)
## Dependencies
- `pocketbase` (passed in as parameter) — for Associations queries
- Browser APIs: DOM, MutationObserver, title attribute
## Notes
- Tooltips use native browser `title` attribute for simplicity and accessibility
- Lookup is by `first_name` only (not full name) to match how names are entered in the UI
- Cache is intentionally in-memory to support real-time Associations updates
+175
View File
@@ -0,0 +1,175 @@
/**
* User Email Lookup Tool
* Provides hover tooltips showing email addresses when hovering over user names
*
* @module UserEmailLookup
* @description Standalone utility for looking up email addresses from the Associations
* collection and displaying them as hover tooltips. Integrates with note/conversation
* sharing UI to enhance user experience when managing shared access.
* @usage Call window.setupUserEmailLookup(pbInstance, containerSelector) after page load.
* Pass PocketBase instance and CSS selector for user capsule container.
* @dependencies PocketBase instance (passed in). Browser APIs: DOM, tooltips via title attribute.
* @integration Used in index.html note/conversation sharing forms to show email on hover
* @gotchas Associations collection must have first_name field for lookups. Lookups are
* performed on hover, so network latency may cause brief delay before tooltip appears.
* Cache email lookups to minimize PocketBase queries.
*/
// Cache for email lookups to minimize queries
const emailLookupCache = {};
let associationsCollectionAvailable = true;
let associationsWarningShown = false;
function getAssociationEmail(record) {
return record?.emailtext || record?.email || 'Email not found';
}
function handleAssociationsError(error) {
const statusCode = Number(error?.status || error?.response?.status || 0);
if (statusCode === 404) {
associationsCollectionAvailable = false;
if (!associationsWarningShown) {
associationsWarningShown = true;
console.warn("'Associations' collection not found. Email hover lookup disabled.");
}
return;
}
console.error('Associations lookup failed:', error);
}
/**
* Fetch email from Associations collection by first name
* Uses cache to avoid duplicate queries
*
* @param {PocketBase} pb - PocketBase instance
* @param {string} firstName - First name to look up
* @returns {Promise<string>} Email address or fallback message
* @example
* const email = await getEmailByFirstName(pb, 'John');
* console.log(email); // 'john@example.com' or 'Email not found'
*/
async function getEmailByFirstName(pb, firstName) {
if (!firstName) return 'Email not found';
if (!associationsCollectionAvailable) return 'Email lookup unavailable';
// Check cache first
if (emailLookupCache[firstName]) {
return emailLookupCache[firstName];
}
try {
const records = await pb.collection('Associations').getList(1, 50, {
filter: `first_name = "${firstName}"`,
});
if (records.items.length === 0) {
emailLookupCache[firstName] = 'Email not found';
return 'Email not found';
}
// Use first match's emailtext field
const firstRecord = records.items[0];
const email = getAssociationEmail(firstRecord);
emailLookupCache[firstName] = email;
return email;
} catch (error) {
handleAssociationsError(error);
return 'Error loading email';
}
}
/**
* Setup email lookups for user capsules in a container
* Attaches hover event listeners to show email in tooltip
*
* @param {PocketBase} pb - PocketBase instance
* @param {string} containerSelector - CSS selector for container with user capsules
* @example
* window.setupUserEmailLookup(pb, '#selectedUsersContainer');
*/
window.setupUserEmailLookup = function(pb, containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) {
console.warn(`setupUserEmailLookup: Container not found: ${containerSelector}`);
return;
}
console.log(`✓ User Email Lookup initialized for: ${containerSelector}`);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.classList?.contains('inline-flex')) {
attachEmailLookupTooltip(pb, node);
}
});
});
});
observer.observe(container, {
childList: true,
subtree: true
});
// Also process any existing capsules
container.querySelectorAll('.inline-flex.items-center.gap-2').forEach((capsule) => {
attachEmailLookupTooltip(pb, capsule);
});
};
/**
* Attach email lookup tooltip to a single user capsule
* Shows email on hover (with caching to prevent redundant queries)
*
* @param {PocketBase} pb - PocketBase instance
* @param {HTMLElement} capsuleEl - The user capsule element
* @private
*/
function attachEmailLookupTooltip(pb, capsuleEl) {
// Extract first name from capsule text
const nameSpan = capsuleEl.querySelector('span:not([data-email-loaded])');
if (!nameSpan || nameSpan.hasAttribute('data-email-loaded')) {
return;
}
const firstName = nameSpan.textContent.trim();
if (!firstName) return;
console.log(`[Email Lookup] Attached to user: ${firstName}`);
// Mark as processed
nameSpan.setAttribute('data-email-loaded', 'true');
// Attach hover listener
capsuleEl.addEventListener('mouseenter', async () => {
console.log(`[Email Lookup] Hover on: ${firstName}`);
// If email already in title, don't query again
if (capsuleEl.title && !capsuleEl.title.startsWith('Loading')) {
return;
}
capsuleEl.title = 'Loading email...';
const email = await getEmailByFirstName(pb, firstName);
capsuleEl.title = email;
capsuleEl.classList.add('cursor-help');
});
capsuleEl.addEventListener('mouseleave', () => {
// Title persists so user can still see it if they hover back quickly
});
}
/**
* Clear the email lookup cache
* Useful if Associations collection is updated and you need fresh data
*
* @example
* window.clearEmailLookupCache();
*/
window.clearEmailLookupCache = function() {
Object.keys(emailLookupCache).forEach(key => delete emailLookupCache[key]);
associationsCollectionAvailable = true;
associationsWarningShown = false;
console.log('✓ Email lookup cache cleared');
};
console.log('✓ User Email Lookup tool loaded - call window.setupUserEmailLookup(pb, containerSelector)');