Add Valkey/Redis cache visualization and API autoloading for graphical inspection

This commit is contained in:
2026-01-01 06:48:52 +00:00
parent 218a15a4c2
commit 6dea66b0cf
8 changed files with 565 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
# Universal Auth Module
## Overview
This module provides a standardized, immutable authentication and token management system for any web project using PocketBase and Microsoft Graph. It supports user and agent tokens, delegated and app-only flows, and a consistent popup UI for login prompts.
## Features
- Pattern-based: Choose 'pb-user', 'pb-agent', 'graph-user', 'graph-agent', or any combination (e.g., 'pb-user+graph-agent') for your project needs
- Supports both user and agent tokens for PocketBase and Microsoft Graph
- Special handling for Valkey/Redis agent login and token storage (for backend/automation flows)
- Single source of truth for token storage, retrieval, and refresh
- Consistent, hidden popup for all user login prompts
- Extracts user info (display name, email) from PocketBase
- Works in any frontend project with PocketBase
## Usage
### 1. Include in your project
```html
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
<script src="auth/auth-universal.js"></script>
```
### 2. Initialize the pattern
```js
// Choose your pattern:
// 'pb-user' - PocketBase user login
// 'pb-agent' - PocketBase agent/service login
// 'graph-user' - Microsoft Graph delegated (user) login
// 'graph-agent' - Microsoft Graph agent/app-only (client credentials)
// Combine as needed: 'pb-user+graph-agent', 'pb-agent+graph-user', etc.
Auth.use('pb-user+graph-agent');
```
### 3. Get and use tokens
```js
const pbToken = await Auth.ensureToken('pb');
const graphToken = await Auth.ensureToken('graph');
const userInfo = Auth.getUserInfo();
```
### 4. Handle reauthentication
- If a token is missing or expired, Auth will automatically show a popup for login.
- The popup is hidden at all other times.
### 5. Clear tokens (for testing or logout)
```js
Auth.clearToken('pb');
Auth.clearToken('graph');
```
## Secrets and Environment
- For frontend, all secrets are handled by PocketBase OAuth (no client secrets exposed).
- For backend/agent flows, use environment variables and server-side code to store secrets securely.
- The module does not expose or require secrets in the frontend.
## Example Test Page
See `auth-test.html` for a safe way to test all flows without affecting your main app.
## Rules for Copilot Instructions
- Always use Auth.use() to set the pattern before requesting tokens. Supported types: 'pb-user', 'pb-agent', 'graph-user', 'graph-agent', or any combination.
- Always use Auth.ensureToken(type) to get a valid token; it will handle refresh and prompt if needed.
- Never store secrets in frontend code; use PocketBase OAuth for user login.
- All login prompts must use the provided popup, never custom dialogs.
- Token keys are immutable: 'pbUserToken', 'pbAgentToken', 'graphUserToken', 'graphAgentToken'.
- User info extraction must use Auth.getUserInfo().
- For agent/app-only tokens, use backend code, environment variables, and optionally Valkey/Redis for secure storage and retrieval.
## FAQ
- **Can I use this in any project?** Yes, as long as you include PocketBase and this module.
- **Does it work for both user and agent tokens?** Yes, with the correct pattern and backend support for agent tokens (including Valkey/Redis for automation).
- **Is the popup customizable?** The style can be changed, but the flow must remain consistent for all projects.
- **How are secrets handled?** Only via backend environment variables for agent tokens; never exposed in frontend.
---
For further integration, see the comments in `auth-universal.js` and the example test page.
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Universal Auth Test</title>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
<script src="auth-universal.js"></script>
<style>
body { font-family: sans-serif; background: #f3f4f6; margin: 0; padding: 2em; }
.test-block { background: #fff; border-radius: 1em; box-shadow: 0 2px 12px #0001; padding: 2em; margin-bottom: 2em; }
.pattern-btn { margin: 0.2em 0.5em 0.2em 0; padding: 0.5em 1.2em; border-radius: 0.5em; border: none; background: #2563eb; color: #fff; cursor: pointer; font-size: 1em; }
.pattern-btn.active { background: #059669; }
#output { font-family: monospace; background: #f9fafb; border-radius: 0.5em; padding: 1em; margin-top: 1em; }
</style>
</head>
<body>
<div class="test-block">
<h2>Choose Auth Pattern</h2>
<div id="patterns"></div>
<div style="margin-top:1em;">
<button onclick="clearAll()" class="pattern-btn" style="background:#b91c1c;">Clear All Tokens</button>
</div>
</div>
<div class="test-block">
<h2>Test Actions</h2>
<button onclick="testToken('pb-user')" class="pattern-btn">Test PB User</button>
<button onclick="testToken('pb-agent')" class="pattern-btn">Test PB Agent</button>
<button onclick="testToken('graph-user')" class="pattern-btn">Test Graph User</button>
<button onclick="testToken('graph-agent')" class="pattern-btn">Test Graph Agent</button>
<button onclick="showUserInfo()" class="pattern-btn" style="background:#6d28d9;">Show User Info</button>
<div id="output"></div>
</div>
<script>
const patterns = [
'pb-user',
'pb-agent',
'graph-user',
'graph-agent',
'pb-user+graph-user',
'pb-user+graph-agent',
'pb-agent+graph-user',
'pb-agent+graph-agent',
'pb-user+pb-agent+graph-user+graph-agent'
];
let currentPattern = patterns[0];
function setPattern(p) {
currentPattern = p;
Auth.use(p);
document.querySelectorAll('.pattern-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.pattern-btn[data-pattern="' + p + '"]').forEach(btn => btn.classList.add('active'));
document.getElementById('output').textContent = `Pattern set: ${p}`;
}
function renderPatterns() {
const el = document.getElementById('patterns');
el.innerHTML = '';
patterns.forEach(p => {
const btn = document.createElement('button');
btn.textContent = p;
btn.className = 'pattern-btn' + (p === currentPattern ? ' active' : '');
btn.setAttribute('data-pattern', p);
btn.onclick = () => setPattern(p);
el.appendChild(btn);
});
}
renderPatterns();
setPattern(currentPattern);
async function testToken(type) {
try {
const token = await Auth.ensureToken(type);
document.getElementById('output').textContent = `${type} token: ${token ? token.substring(0, 40) + '...' : 'NONE'}`;
} catch (err) {
document.getElementById('output').textContent = `Error: ${err.message}`;
}
}
function clearAll() {
['pb-user','pb-agent','graph-user','graph-agent'].forEach(Auth.clearToken);
document.getElementById('output').textContent = 'All tokens cleared.';
}
function showUserInfo() {
const info = Auth.getUserInfo();
document.getElementById('output').textContent = 'User Info: ' + JSON.stringify(info, null, 2);
}
</script>
</body>
</html>
+220
View File
@@ -0,0 +1,220 @@
// Universal Auth Module: Immutable, Pattern-Based, Project-Agnostic
// Usage: import and call Auth.use('pb-graph') or Auth.use('pb') or Auth.use('graph')
// Provides: getToken(type), ensureToken(type), refreshToken(type), getUserInfo(), promptReauth()
// Token types: 'pb' (PocketBase user/agent), 'graph' (Graph delegated), 'graphAgent' (Graph app-only)
// All storage, retrieval, refresh, and UI are standardized and immutable
const TOKEN_KEYS = {
'pb-user': 'pbUserToken',
'pb-agent': 'pbAgentToken',
'graph-user': 'graphUserToken',
'graph-agent': 'graphAgentToken'
};
function parsePattern(pattern) {
return pattern.split('+').map(s => s.trim()).filter(Boolean);
}
window.Auth = (() => {
let pattern = 'pb-user'; // default
let enabledTypes = ['pb-user'];
let pb = null;
let popup = null;
let agentCreds = { email: '', password: '' };
let superuserCreds = { email: '', password: '' };
// --- Setup ---
function use(type) {
pattern = type;
enabledTypes = parsePattern(type);
if (enabledTypes.some(t => t.startsWith('pb'))) {
pb = window.PocketBase ? new PocketBase('https://pocketbase.ccllc.pro') : null;
}
// Only one-time setup
if (!document.getElementById('authPopup')) {
createPopup();
}
}
// --- Token Storage ---
function getToken(type) {
return localStorage.getItem(TOKEN_KEYS[type]) || '';
}
function setToken(type, value) {
if (value) localStorage.setItem(TOKEN_KEYS[type], value);
}
function clearToken(type) {
localStorage.removeItem(TOKEN_KEYS[type]);
}
// --- Info Extraction ---
function getUserInfo() {
if (pb && pb.authStore.model) {
return {
displayName: pb.authStore.model.display_name || pb.authStore.model.name || pb.authStore.model.email || pb.authStore.model.username || 'Unknown',
email: pb.authStore.model.email || pb.authStore.model.username || null
};
}
return { displayName: '', email: '' };
}
// --- Token Ensure/Refresh ---
async function ensureToken(type) {
let token = getToken(type);
if (token) return token;
if (type === 'pb-user' && pb) {
if (pb.authStore.isValid) {
setToken('pb-user', pb.authStore.token);
return pb.authStore.token;
}
// Choose login method: email/password or OAuth
if (pattern.includes('pb-user-oauth')) {
return await promptReauth('pb-user-oauth');
} else {
return await promptReauth('pb-user');
}
}
if (type === 'pb-superuser' && pb) {
// Prompt for superuser credentials
await promptReauth('pb-superuser');
// Use superuser credentials to login
const authData = await pb.collection('_superuser').authWithPassword(superuserCreds.email, superuserCreds.password);
setToken('pb-superuser', pb.authStore.token);
return pb.authStore.token;
}
if (type === 'pb-agent' && pb) {
// Prompt for agent credentials if not set
if (!agentCreds.email || !agentCreds.password) {
await promptReauth('pb-agent');
}
// Use agent credentials to login
const authData = await pb.collection('users').authWithPassword(agentCreds.email, agentCreds.password);
setToken('pb-agent', pb.authStore.token);
return pb.authStore.token;
}
if (type === 'graph-user' && pb) {
// OAuth only
return await promptReauth('graph-user');
}
if (type === 'graph-agent') {
// Prompt for agent credentials if not set
if (!agentCreds.email || !agentCreds.password) {
await promptReauth('graph-agent');
}
// Use agent credentials to get token (simulate, real flow is backend)
setToken('graph-agent', 'AGENT_TOKEN_' + agentCreds.email);
return getToken('graph-agent');
}
throw new Error('Unknown token type');
}
async function refreshToken(type) {
if (type === 'graph-user' && pb) {
try {
const authData = await pb.collection('users').authRefresh();
const meta = authData?.meta || pb?.authStore?.model?.meta || {};
const token = meta.graphAccessToken || meta.graph_token || meta.graphToken || meta.accessToken || meta.access_token || meta.token || meta.rawToken || meta?.authData?.access_token || '';
setToken('graph-user', token);
return token || '';
} catch (err) {
return '';
}
}
// Add refresh logic for other types as needed
return '';
}
// --- Reauth Popup ---
function createPopup() {
popup = document.createElement('div');
popup.id = 'authPopup';
popup.style = 'display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.4);z-index:9999;align-items:center;justify-content:center;';
popup.innerHTML = `
<div style="background:#fff;padding:2em 2.5em;border-radius:1em;box-shadow:0 2px 24px #0002;text-align:center;max-width:350px;margin:auto;">
<h2 style="font-size:1.3em;margin-bottom:1em;">Sign In Required</h2>
<div id="authPopupForms"></div>
<div id="authPopupError" style="color:#b91c1c;margin-top:1em;display:none;"></div>
</div>
`;
document.body.appendChild(popup);
}
function showForm(type) {
const forms = {
'pb-user': `<input id="pbUserEmail" type="email" placeholder="Email" style="width:90%;margin-bottom:0.5em;"><br><input id="pbUserPassword" type="password" placeholder="Password" style="width:90%;margin-bottom:0.5em;"><br><button id="authPopupBtn" style="background:#2563eb;color:#fff;padding:0.7em 2em;border:none;border-radius:0.5em;font-size:1em;cursor:pointer;">Sign in</button>`,
'pb-user-oauth': `<button id="authPopupBtn" style="background:#2563eb;color:#fff;padding:0.7em 2em;border:none;border-radius:0.5em;font-size:1em;cursor:pointer;">Sign in with Microsoft</button>`,
'pb-superuser': `<input id="pbSuperuserEmail" type="email" placeholder="Superuser Email" style="width:90%;margin-bottom:0.5em;"><br><input id="pbSuperuserPassword" type="password" placeholder="Superuser Password" style="width:90%;margin-bottom:0.5em;"><br><button id="authPopupBtn" style="background:#d97706;color:#fff;padding:0.7em 2em;border:none;border-radius:0.5em;font-size:1em;cursor:pointer;">Sign in as Superuser</button>`,
'pb-agent': `<input id="pbAgentEmail" type="email" placeholder="Agent Email" style="width:90%;margin-bottom:0.5em;"><br><input id="pbAgentPassword" type="password" placeholder="Agent Password" style="width:90%;margin-bottom:0.5em;"><br><button id="authPopupBtn" style="background:#059669;color:#fff;padding:0.7em 2em;border:none;border-radius:0.5em;font-size:1em;cursor:pointer;">Sign in as Agent</button>`,
'graph-user': `<button id="authPopupBtn" style="background:#2563eb;color:#fff;padding:0.7em 2em;border:none;border-radius:0.5em;font-size:1em;cursor:pointer;">Sign in with Microsoft</button>`,
'graph-agent': `<input id="graphAgentEmail" type="email" placeholder="Agent Email" style="width:90%;margin-bottom:0.5em;"><br><input id="graphAgentPassword" type="password" placeholder="Agent Password" style="width:90%;margin-bottom:0.5em;"><br><button id="authPopupBtn" style="background:#059669;color:#fff;padding:0.7em 2em;border:none;border-radius:0.5em;font-size:1em;cursor:pointer;">Sign in as Graph Agent</button>`
};
document.getElementById('authPopupForms').innerHTML = forms[type] || '';
}
async function promptReauth(type) {
showForm(type);
popup.style.display = 'flex';
return new Promise((resolve, reject) => {
document.getElementById('authPopupBtn').onclick = async () => {
try {
if (type === 'pb-user') {
const email = document.getElementById('pbUserEmail').value;
const password = document.getElementById('pbUserPassword').value;
const authData = await pb.collection('users').authWithPassword(email, password);
setToken('pb-user', pb.authStore.token);
popup.style.display = 'none';
resolve(pb.authStore.token);
} else if (type === 'pb-user-oauth') {
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
setToken('pb-user', authData?.meta?.graphAccessToken || '');
popup.style.display = 'none';
resolve(authData?.meta?.graphAccessToken || '');
} else if (type === 'pb-superuser') {
superuserCreds.email = document.getElementById('pbSuperuserEmail').value;
superuserCreds.password = document.getElementById('pbSuperuserPassword').value;
popup.style.display = 'none';
resolve();
} else if (type === 'pb-agent') {
agentCreds.email = document.getElementById('pbAgentEmail').value;
agentCreds.password = document.getElementById('pbAgentPassword').value;
popup.style.display = 'none';
resolve();
} else if (type === 'graph-user') {
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
setToken('graph-user', authData?.meta?.graphAccessToken || '');
popup.style.display = 'none';
resolve(authData?.meta?.graphAccessToken || '');
} else if (type === 'graph-agent') {
agentCreds.email = document.getElementById('graphAgentEmail').value;
agentCreds.password = document.getElementById('graphAgentPassword').value;
popup.style.display = 'none';
resolve();
}
} catch (err) {
document.getElementById('authPopupError').textContent = err.message || 'Authentication failed.';
document.getElementById('authPopupError').style.display = '';
}
};
});
}
// --- API ---
return {
use,
getToken,
setToken,
clearToken,
getUserInfo,
ensureToken,
refreshToken,
promptReauth
};
})();
window.Auth = Auth;
// Example usage:
// Auth.use('pb-graph');
// const token = await Auth.ensureToken('graph');
// const user = Auth.getUserInfo();
// All login prompts are handled via the popup, which is hidden unless needed.
+29
View File
@@ -0,0 +1,29 @@
// Simple Express API to serve all Redis/Valkey cache keys and values as JSON (CommonJS)
const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
app.get('/api/cache', async (req, res) => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const port = process.env.CACHE_API_PORT || 3006;
app.listen(port, () => {
console.log(`Cache API running on http://localhost:${port}/api/cache`);
});
+29
View File
@@ -0,0 +1,29 @@
// Simple Express API to serve all Redis/Valkey cache keys and values as JSON
const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
app.get('/api/cache', async (req, res) => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const port = process.env.CACHE_API_PORT || 3030;
app.listen(port, () => {
console.log(`Cache API running on http://localhost:${port}/api/cache`);
});
+25
View File
@@ -0,0 +1,25 @@
// Script to list all Redis/Valkey keys and their values for visualization (CommonJS)
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
(async () => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (err) {
console.error('Error listing Redis keys:', err);
process.exit(1);
}
})();
+25
View File
@@ -0,0 +1,25 @@
// Script to list all Redis/Valkey keys and their values for visualization
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
(async () => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (err) {
console.error('Error listing Redis keys:', err);
process.exit(1);
}
})();
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Valkey/Redis Cache Visualization</title>
<style>
body { font-family: sans-serif; background: #f8fafc; margin: 0; padding: 2em; }
h1 { color: #2563eb; }
table { border-collapse: collapse; width: 100%; background: #fff; }
th, td { border: 1px solid #e5e7eb; padding: 0.5em 1em; }
th { background: #f1f5f9; }
tr:nth-child(even) { background: #f9fafb; }
.json { font-family: monospace; font-size: 0.95em; white-space: pre; }
.expand { cursor: pointer; color: #2563eb; text-decoration: underline; }
.search { margin-bottom: 1em; padding: 0.5em; width: 300px; }
</style>
</head>
<body>
<h1>Valkey/Redis Cache Visualization</h1>
<input class="search" type="text" id="search" placeholder="Search by key or value..." />
<table id="cacheTable">
<thead>
<tr><th>Key</th><th>Value</th></tr>
</thead>
<tbody></tbody>
</table>
<script>
let cacheData = [];
function renderTable(data) {
const tbody = document.querySelector('#cacheTable tbody');
tbody.innerHTML = '';
for (const { key, value } of data) {
const tr = document.createElement('tr');
const tdKey = document.createElement('td');
tdKey.textContent = key;
const tdValue = document.createElement('td');
if (typeof value === 'object' && value !== null) {
const btn = document.createElement('span');
btn.textContent = '[expand]';
btn.className = 'expand';
btn.onclick = () => {
btn.outerHTML = `<div class='json'>${JSON.stringify(value, null, 2)}</div>`;
};
tdValue.appendChild(btn);
} else {
tdValue.textContent = String(value);
}
tr.appendChild(tdKey);
tr.appendChild(tdValue);
tbody.appendChild(tr);
}
}
// Fetch cache data from API
async function loadCache() {
try {
const res = await fetch('http://localhost:3006/api/cache');
cacheData = await res.json();
renderTable(cacheData);
} catch (err) {
document.querySelector('#cacheTable tbody').innerHTML = `<tr><td colspan='2' style='color:#b91c1c'>Failed to load cache data: ${err}</td></tr>`;
}
}
// Search functionality
document.getElementById('search').addEventListener('input', function() {
const q = this.value.toLowerCase();
renderTable(cacheData.filter(({key, value}) =>
key.toLowerCase().includes(q) || JSON.stringify(value).toLowerCase().includes(q)
));
});
// Initial load
loadCache();
</script>
</body>
</html>