From f6ef573f3ac6d3792debbc6df7711cd6c05a43fd Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 23 Apr 2026 12:37:04 +0200 Subject: [PATCH] Auth: Implement automatic session refresh logic to prevent 'JWT expired' errors. The app now handles token renewal in the background, allowing users to save changes without interruption. --- .agents/skills/find-skills/SKILL.md | 142 ++++++++++++++++++++++++++++ .claude/skills/find-skills | 1 + .qwen/skills/find-skills | 1 + skills-lock.json | 10 ++ skills/find-skills | 1 + src/App.tsx | 33 ++++--- src/components/HistoryView.tsx | 5 +- src/lib/auth.ts | 28 ++++++ src/lib/supabase.ts | 74 +++++++++------ 9 files changed, 249 insertions(+), 46 deletions(-) create mode 100644 .agents/skills/find-skills/SKILL.md create mode 120000 .claude/skills/find-skills create mode 120000 .qwen/skills/find-skills create mode 100644 skills-lock.json create mode 120000 skills/find-skills diff --git a/.agents/skills/find-skills/SKILL.md b/.agents/skills/find-skills/SKILL.md new file mode 100644 index 0000000..114c663 --- /dev/null +++ b/.agents/skills/find-skills/SKILL.md @@ -0,0 +1,142 @@ +--- +name: find-skills +description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill. +--- + +# Find Skills + +This skill helps you discover and install skills from the open agent skills ecosystem. + +## When to Use This Skill + +Use this skill when the user: + +- Asks "how do I do X" where X might be a common task with an existing skill +- Says "find a skill for X" or "is there a skill for X" +- Asks "can you do X" where X is a specialized capability +- Expresses interest in extending agent capabilities +- Wants to search for tools, templates, or workflows +- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.) + +## What is the Skills CLI? + +The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools. + +**Key commands:** + +- `npx skills find [query]` - Search for skills interactively or by keyword +- `npx skills add ` - Install a skill from GitHub or other sources +- `npx skills check` - Check for skill updates +- `npx skills update` - Update all installed skills + +**Browse skills at:** https://skills.sh/ + +## How to Help Users Find Skills + +### Step 1: Understand What They Need + +When a user asks for help with something, identify: + +1. The domain (e.g., React, testing, design, deployment) +2. The specific task (e.g., writing tests, creating animations, reviewing PRs) +3. Whether this is a common enough task that a skill likely exists + +### Step 2: Check the Leaderboard First + +Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options. + +For example, top skills for web development include: +- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each) +- `anthropics/skills` — Frontend design, document processing (100K+ installs) + +### Step 3: Search for Skills + +If the leaderboard doesn't cover the user's need, run the find command: + +```bash +npx skills find [query] +``` + +For example: + +- User asks "how do I make my React app faster?" → `npx skills find react performance` +- User asks "can you help me with PR reviews?" → `npx skills find pr review` +- User asks "I need to create a changelog" → `npx skills find changelog` + +### Step 4: Verify Quality Before Recommending + +**Do not recommend a skill based solely on search results.** Always verify: + +1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100. +2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors. +3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism. + +### Step 5: Present Options to the User + +When you find relevant skills, present them to the user with: + +1. The skill name and what it does +2. The install count and source +3. The install command they can run +4. A link to learn more at skills.sh + +Example response: + +``` +I found a skill that might help! The "react-best-practices" skill provides +React and Next.js performance optimization guidelines from Vercel Engineering. +(185K installs) + +To install it: +npx skills add vercel-labs/agent-skills@react-best-practices + +Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices +``` + +### Step 6: Offer to Install + +If the user wants to proceed, you can install the skill for them: + +```bash +npx skills add -g -y +``` + +The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts. + +## Common Skill Categories + +When searching, consider these common categories: + +| Category | Example Queries | +| --------------- | ---------------------------------------- | +| Web Development | react, nextjs, typescript, css, tailwind | +| Testing | testing, jest, playwright, e2e | +| DevOps | deploy, docker, kubernetes, ci-cd | +| Documentation | docs, readme, changelog, api-docs | +| Code Quality | review, lint, refactor, best-practices | +| Design | ui, ux, design-system, accessibility | +| Productivity | workflow, automation, git | + +## Tips for Effective Searches + +1. **Use specific keywords**: "react testing" is better than just "testing" +2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd" +3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills` + +## When No Skills Are Found + +If no relevant skills exist: + +1. Acknowledge that no existing skill was found +2. Offer to help with the task directly using your general capabilities +3. Suggest the user could create their own skill with `npx skills init` + +Example: + +``` +I searched for skills related to "xyz" but didn't find any matches. +I can still help you with this task directly! Would you like me to proceed? + +If this is something you do often, you could create your own skill: +npx skills init my-xyz-skill +``` diff --git a/.claude/skills/find-skills b/.claude/skills/find-skills new file mode 120000 index 0000000..0b17e35 --- /dev/null +++ b/.claude/skills/find-skills @@ -0,0 +1 @@ +../../.agents/skills/find-skills \ No newline at end of file diff --git a/.qwen/skills/find-skills b/.qwen/skills/find-skills new file mode 120000 index 0000000..0b17e35 --- /dev/null +++ b/.qwen/skills/find-skills @@ -0,0 +1 @@ +../../.agents/skills/find-skills \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..cbaaf9e --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "find-skills": { + "source": "vercel-labs/skills", + "sourceType": "github", + "computedHash": "9e1c8b3103f92fa8092568a44fe64858de7c5c9dc65ce4bea8f168080e889cfd" + } + } +} diff --git a/skills/find-skills b/skills/find-skills new file mode 120000 index 0000000..0b5a147 --- /dev/null +++ b/skills/find-skills @@ -0,0 +1 @@ +../.agents/skills/find-skills \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 1c6c943..52c7622 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,17 @@ export default function App() { setSession(null); }; + // Sync session state when localStorage is updated (e.g. by token refresh) + useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key === 'craze_auth_session') { + setSession(getStoredSession()); + } + }; + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, []); + useEffect(() => { const loadDefaultData = async () => { setIsLoadingDefault(true); @@ -122,7 +133,7 @@ export default function App() { } console.log('Fetching synced data from Supabase...'); - const syncedData = await getAllSyncedRows(session?.access_token); + const syncedData = await getAllSyncedRows(); const resolvedCols = resolveColumnIndices(headers); const editableColumns = new Set([ @@ -316,7 +327,7 @@ export default function App() { // 3. Post-export: Reset pending statuses in Supabase console.log('Resetting pending statuses in Supabase...'); - resetAllPendingRows(session?.access_token).then(success => { + resetAllPendingRows().then(success => { if (success) { console.log('Successfully reset all pending statuses'); setRowStatuses({}); // Clear local statuses @@ -414,18 +425,17 @@ export default function App() { setIsSavingAll(true); let failedArticles: string[] = []; - let jwtExpired = false; - const token = session?.access_token; + let sessionIssue = false; try { for (const [articleNo, { newData, originalData, articleName }] of entries) { console.log('[handleSaveAll] Saving article:', articleNo); - const result = await saveRowToSupabase(articleNo, newData, token); + const result = await saveRowToSupabase(articleNo, newData); console.log('[handleSaveAll] Save result for', articleNo, ':', result); if (result.success) { // Also save to history - await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token); + await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown'); setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' })); setPendingRows(prev => { @@ -436,16 +446,16 @@ export default function App() { } else { setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' })); failedArticles.push(`${articleNo} [${result.error || 'Unknown error'}]`); - if (result.error?.includes('401') || result.error?.includes('JWT expired')) { - jwtExpired = true; + if (result.error?.includes('401') || result.error?.includes('expired') || result.error?.includes('Session expired')) { + sessionIssue = true; } } } console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length); - if (jwtExpired) { - alert("Tu sesión ha caducado (JWT expired). Por favor, haz clic en el botón de Cerrar Sesión (Sign out) arriba a la derecha y vuelve a iniciar sesión para guardar tus cambios."); + if (sessionIssue) { + alert("Tu sesión ha caducado definitivamente. Por favor, cierra sesión e inicia sesión de nuevo para continuar. No refresques la página para no perder tus cambios pendientes en pantalla."); } else if (failedArticles.length > 0) { alert(`Failed to save items:\n\n${failedArticles.join('\n')}\n\nPlease try again.`); } else { @@ -667,7 +677,6 @@ export default function App() { setEditingRowIndex(index)} onRevert={async (articleNo, revertedData, historyId) => { // Find the row in appState.data and update it @@ -691,7 +700,7 @@ export default function App() { })); // Delete the history entry after revert if (historyId) { - await deleteHistoryEntry(String(historyId), session?.access_token); + await deleteHistoryEntry(String(historyId)); } } }} diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx index 9a9c5e5..0333d1a 100644 --- a/src/components/HistoryView.tsx +++ b/src/components/HistoryView.tsx @@ -10,10 +10,9 @@ interface HistoryViewProps { data: ExcelRow[]; onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void; onEdit?: (rowIndex: number) => void; - sessionToken?: string; } -export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) { +export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProps) { const COLUMNS = useColumns(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); @@ -26,7 +25,7 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H const loadHistory = async () => { setLoading(true); - const data = await getHistory(sessionToken); + const data = await getHistory(); setHistory(data); setLoading(false); }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 82d29b5..3e7cde8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,6 +4,7 @@ const SESSION_KEY = 'craze_auth_session'; export interface AuthSession { access_token: string; + refresh_token: string; user: { id: string; email: string }; } @@ -41,6 +42,33 @@ export async function signIn(email: string, password: string): Promise { + const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': SUPABASE_ANON_KEY, + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + localStorage.removeItem(SESSION_KEY); + throw new Error('Session expired. Please sign in again.'); + } + + const data = await response.json(); + const session: AuthSession = { + access_token: data.access_token, + refresh_token: data.refresh_token, user: { id: data.user.id, email: data.user.email }, }; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index eefb1f8..50863df 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,11 +1,36 @@ +import { refreshSession, getStoredSession } from './auth'; + const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; -function authHeaders(token?: string): Record { - return { +async function safeFetch(url: string, options: RequestInit = {}): Promise { + const session = getStoredSession(); + const token = session?.access_token || SUPABASE_ANON_KEY; + + const headers = { + ...options.headers, 'apikey': SUPABASE_ANON_KEY, - 'Authorization': `Bearer ${token ?? SUPABASE_ANON_KEY}`, + 'Authorization': `Bearer ${token}`, }; + + let response = await fetch(url, { ...options, headers }); + + if (response.status === 401 && session?.refresh_token) { + console.log('JWT expired, attempting refresh...'); + try { + const newSession = await refreshSession(session.refresh_token); + const newHeaders = { + ...options.headers, + 'apikey': SUPABASE_ANON_KEY, + 'Authorization': `Bearer ${newSession.access_token}`, + }; + response = await fetch(url, { ...options, headers: newHeaders }); + } catch (refreshError) { + console.error('Session refresh failed:', refreshError); + } + } + + return response; } export interface ExcelRow extends Array {} @@ -15,14 +40,11 @@ export interface SyncedRow { status?: 'pending' | 'synced'; } -export async function getAllSyncedRows(token?: string): Promise> { +export async function getAllSyncedRows(): Promise> { try { - const response = await fetch( + const response = await safeFetch( `${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`, - { - cache: 'no-store', - headers: authHeaders(token), - } + { cache: 'no-store' } ); if (!response.ok) { @@ -42,15 +64,14 @@ export async function getAllSyncedRows(token?: string): Promise { +export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<{ success: boolean; error?: string }> { try { - const response = await fetch( + const response = await safeFetch( `${SUPABASE_URL}/rest/v1/products`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...authHeaders(token), 'Prefer': 'resolution=merge-duplicates', }, body: JSON.stringify({ @@ -92,17 +113,15 @@ export async function saveHistoryEntry( articleName: string, oldData: ExcelRow, newData: ExcelRow, - changedBy: string, - token?: string + changedBy: string ): Promise<{ success: boolean; error?: string }> { try { - const response = await fetch( + const response = await safeFetch( `${SUPABASE_URL}/rest/v1/products_history`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...authHeaders(token), 'Prefer': 'return=minimal', }, body: JSON.stringify({ @@ -131,14 +150,11 @@ export async function saveHistoryEntry( } } -export async function getHistory(token?: string): Promise { +export async function getHistory(): Promise { try { - const response = await fetch( + const response = await safeFetch( `${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`, - { - cache: 'no-store', - headers: authHeaders(token), - } + { cache: 'no-store' } ); if (!response.ok) { @@ -168,14 +184,11 @@ export async function getHistory(token?: string): Promise { } } -export async function deleteHistoryEntry(id: string, token?: string): Promise { +export async function deleteHistoryEntry(id: string): Promise { try { - const response = await fetch( + const response = await safeFetch( `${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`, - { - method: 'DELETE', - headers: authHeaders(token), - } + { method: 'DELETE' } ); return response.ok; @@ -185,15 +198,14 @@ export async function deleteHistoryEntry(id: string, token?: string): Promise { +export async function resetAllPendingRows(): Promise { try { - const response = await fetch( + const response = await safeFetch( `${SUPABASE_URL}/rest/v1/products?status=eq.pending`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', - ...authHeaders(token), 'Prefer': 'return=minimal', }, body: JSON.stringify({ status: 'synced' })