Compare commits
32 Commits
9ced9fd4ed
...
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 |
@@ -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.
|
- `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.
|
- `/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.
|
- 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
-2773
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>
|
||||||
@@ -167,4 +167,82 @@ Project history, decisions, and architectural milestones
|
|||||||
becomes more established and needs full file separation
|
becomes more established and needs full file separation
|
||||||
- Status: Functional and ready for independent container updates
|
- 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"
|
"start": "bun run server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^5.6.1",
|
||||||
"@azure/msal-node": "^2.6.7",
|
"@azure/msal-node": "^2.6.7",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"dotenv": "^16.4.5",
|
"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>
|
||||||
@@ -17,6 +17,25 @@
|
|||||||
|
|
||||||
// Cache for email lookups to minimize queries
|
// Cache for email lookups to minimize queries
|
||||||
const emailLookupCache = {};
|
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
|
* Fetch email from Associations collection by first name
|
||||||
@@ -31,6 +50,7 @@ const emailLookupCache = {};
|
|||||||
*/
|
*/
|
||||||
async function getEmailByFirstName(pb, firstName) {
|
async function getEmailByFirstName(pb, firstName) {
|
||||||
if (!firstName) return 'Email not found';
|
if (!firstName) return 'Email not found';
|
||||||
|
if (!associationsCollectionAvailable) return 'Email lookup unavailable';
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if (emailLookupCache[firstName]) {
|
if (emailLookupCache[firstName]) {
|
||||||
@@ -41,18 +61,19 @@ async function getEmailByFirstName(pb, firstName) {
|
|||||||
const records = await pb.collection('Associations').getList(1, 50, {
|
const records = await pb.collection('Associations').getList(1, 50, {
|
||||||
filter: `first_name = "${firstName}"`,
|
filter: `first_name = "${firstName}"`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (records.items.length === 0) {
|
if (records.items.length === 0) {
|
||||||
emailLookupCache[firstName] = 'Email not found';
|
emailLookupCache[firstName] = 'Email not found';
|
||||||
return 'Email not found';
|
return 'Email not found';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use first match's email
|
// Use first match's emailtext field
|
||||||
const email = records.items[0].email || 'Email not found';
|
const firstRecord = records.items[0];
|
||||||
|
const email = getAssociationEmail(firstRecord);
|
||||||
emailLookupCache[firstName] = email;
|
emailLookupCache[firstName] = email;
|
||||||
return email;
|
return email;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to lookup email for "${firstName}":`, error);
|
handleAssociationsError(error);
|
||||||
return 'Error loading email';
|
return 'Error loading email';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,8 +95,6 @@ window.setupUserEmailLookup = function(pb, containerSelector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✓ User Email Lookup initialized for: ${containerSelector}`);
|
console.log(`✓ User Email Lookup initialized for: ${containerSelector}`);
|
||||||
|
|
||||||
// Observer: Watch for new capsule additions
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
mutations.forEach((mutation) => {
|
mutations.forEach((mutation) => {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
@@ -115,11 +134,14 @@ function attachEmailLookupTooltip(pb, capsuleEl) {
|
|||||||
const firstName = nameSpan.textContent.trim();
|
const firstName = nameSpan.textContent.trim();
|
||||||
if (!firstName) return;
|
if (!firstName) return;
|
||||||
|
|
||||||
|
console.log(`[Email Lookup] Attached to user: ${firstName}`);
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
nameSpan.setAttribute('data-email-loaded', 'true');
|
nameSpan.setAttribute('data-email-loaded', 'true');
|
||||||
|
|
||||||
// Attach hover listener
|
// Attach hover listener
|
||||||
capsuleEl.addEventListener('mouseenter', async () => {
|
capsuleEl.addEventListener('mouseenter', async () => {
|
||||||
|
console.log(`[Email Lookup] Hover on: ${firstName}`);
|
||||||
// If email already in title, don't query again
|
// If email already in title, don't query again
|
||||||
if (capsuleEl.title && !capsuleEl.title.startsWith('Loading')) {
|
if (capsuleEl.title && !capsuleEl.title.startsWith('Loading')) {
|
||||||
return;
|
return;
|
||||||
@@ -145,6 +167,8 @@ function attachEmailLookupTooltip(pb, capsuleEl) {
|
|||||||
*/
|
*/
|
||||||
window.clearEmailLookupCache = function() {
|
window.clearEmailLookupCache = function() {
|
||||||
Object.keys(emailLookupCache).forEach(key => delete emailLookupCache[key]);
|
Object.keys(emailLookupCache).forEach(key => delete emailLookupCache[key]);
|
||||||
|
associationsCollectionAvailable = true;
|
||||||
|
associationsWarningShown = false;
|
||||||
console.log('✓ Email lookup cache cleared');
|
console.log('✓ Email lookup cache cleared');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user