Compare commits
34 Commits
534016e9d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5834bd1e60 | |||
| a5408ba8da | |||
| 602c275644 | |||
| d70a8b64c6 | |||
| a045a1fbd8 | |||
| a34b362683 | |||
| 69c8d80ef2 | |||
| e6b727b345 | |||
| b3b2a2e2f0 | |||
| 006f976f57 | |||
| 49824ea3c6 | |||
| 6abaf6c778 | |||
| d042f00230 | |||
| 9a6da32504 | |||
| 21c8b4b4c7 | |||
| c4c5906daf | |||
| e9d520fd5e | |||
| 744adc8645 | |||
| 7ecb7256de | |||
| da1a97b8fd | |||
| 94d6840ef7 | |||
| 17904cff3a | |||
| d6b0e3192a | |||
| 8ca2c97e37 | |||
| 513e975530 | |||
| c76739d0d7 | |||
| 04ae0c0c82 | |||
| 4c6058c656 | |||
| 864fcab1ce | |||
| 1b751742dc | |||
| 95888562a0 | |||
| 7c4459fd47 | |||
| 9ced9fd4ed | |||
| efbe5d6573 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
================================================================================
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3408
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+500
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)');
|
||||
Reference in New Issue
Block a user