diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 61646f0..69330e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -95,3 +95,18 @@ When working on this project, prioritize user experience, type safety, and maint ## Compatibility Policy - **Do NOT add compatibility helpers or legacy mapping code:** Fail fast on missing columns or schema mismatches. Avoid adding server-side compatibility shims that map legacy `fitbuddyai_*` keys to new columns. These helpers create code bloat and hidden behavior; prefer explicit schema changes and migrations. - **Do NOT use local-file fallbacks or silent dev fallbacks in server code:** This project is production-first. Server code must require Supabase (or the configured production datastore) and fail loudly if it is not available. Do not add behavior that reads or writes local JSON files as a runtime fallback — that hides configuration problems and leads to inconsistent production behavior. + + +FitBuddyAI Copilot Guidelines + +- This repository is production-grade and must be treated as such. +- Do NOT add placeholder comments or TODOs that indicate unfinished production work. +- Do NOT add comments that state "this is a placeholder" or similar developer-only notes. + - When the user requests a fix, implement the code change; do not respond by + only adding explanatory comments in the code instead of performing the + requested fix. +- All code added should be runnable, properly tested, and follow existing project patterns. +- If a dev-only helper is required, clearly gate it behind NODE_ENV checks and provide a corresponding test or cleanup plan. +- Sensitive configuration must be stored in environment variables; do not hard-code secrets. + +Rationale: This project is deployed to production environments and security, clarity, and maintainability are required. Keep contributions focused and production-ready. diff --git a/.github/workflows/refresh-token-cleanup.yml b/.github/workflows/refresh-token-cleanup.yml new file mode 100644 index 0000000..c0d0f91 --- /dev/null +++ b/.github/workflows/refresh-token-cleanup.yml @@ -0,0 +1,26 @@ +name: Refresh Token Cleanup +permissions: + contents: read + +on: + schedule: + - cron: '0 3 * * *' # every day at 03:00 UTC + workflow_dispatch: {} + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install deps + run: npm ci + - name: Run cleanup script + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: node scripts/cleanup_refresh_tokens.js 30 diff --git a/README.md b/README.md index 32a8dc4..b0a3f91 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,19 @@ If automation is failing or you prefer manual control, change the preview link y SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key JWT_SECRET=your-secure-jwt-secret-here ADMIN_API_TOKEN=your-admin-api-token + + # Encryption keys for server-side refresh-token storage (required for dev) + # Preferred: provide multiple keys for rotation in order (newest first): + # REFRESH_TOKEN_ENC_KEYS==,= + # Example: + # REFRESH_TOKEN_ENC_KEYS=k2=NEW_SECRET,k1=OLD_SECRET + # Legacy single-key option (not recommended for rotation): + # REFRESH_TOKEN_ENC_KEY=your-secret + # REFRESH_TOKEN_ENC_KEY_ID=k1 ``` + Note: The dev auth server requires `REFRESH_TOKEN_ENC_KEY` (or `REFRESH_TOKEN_ENC_KEYS`) to be set and will fail to start without it. In production, set the same variables in your deployment environment. Use the multi-key `REFRESH_TOKEN_ENC_KEYS` format to rotate keys safely: add the new key first, leave old keys present until tokens have migrated, then remove old keys. + 4. **Generate Secure JWT Secret** ```bash node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" diff --git a/api/auth/index.ts b/api/auth/index.ts index ba67422..20814d7 100644 --- a/api/auth/index.ts +++ b/api/auth/index.ts @@ -1,6 +1,7 @@ import { createClient } from '@supabase/supabase-js'; import { v4 as uuidv4 } from 'uuid'; import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; const SUPABASE_URL = process.env.SUPABASE_URL as string | undefined; const SUPABASE_KEY = process.env.SUPABASE_KEY as string | undefined; @@ -11,11 +12,197 @@ if (!SUPABASE_URL || !SUPABASE_KEY) { const supabase = createClient(SUPABASE_URL || '', SUPABASE_KEY || ''); +// Encryption helpers for refresh tokens (AES-256-GCM) +const ENC_ALGO = 'aes-256-gcm'; + +// Key rotation support +// Accept multiple keys from environment in two ways (priority order): +// - REFRESH_TOKEN_ENC_KEYS as a comma-separated list of keyId=secret +// e.g. "k1=oldsecret,k2=newsecret" (first entry is considered current) +// - REFRESH_TOKEN_ENC_KEY (single legacy secret) +// Optionally specify REFRESH_TOKEN_ENC_KEY_ID for the single-key id. +const parseEncKeys = () => { + const out: { id: string; raw: string; key: Buffer }[] = []; + const multi = (process.env.REFRESH_TOKEN_ENC_KEYS || '').toString().trim(); + if (multi) { + const parts = multi.split(',').map((p) => p.trim()).filter(Boolean); + for (const p of parts) { + const [id, ...rest] = p.split('='); + if (!id || rest.length === 0) continue; + const raw = rest.join('=').trim(); + if (!raw) continue; + out.push({ id: id.trim(), raw, key: crypto.createHash('sha256').update(raw).digest() }); + } + } else { + const legacy = (process.env.REFRESH_TOKEN_ENC_KEY || process.env.REFRESH_TOKEN_KEY || '').toString().trim(); + if (legacy) { + const id = (process.env.REFRESH_TOKEN_ENC_KEY_ID || 'k1').toString().trim() || 'k1'; + out.push({ id, raw: legacy, key: crypto.createHash('sha256').update(legacy).digest() }); + } + } + return out; +}; + +const ENC_KEYS = parseEncKeys(); +if (!ENC_KEYS || ENC_KEYS.length === 0) { + throw new Error('[api/auth] REFRESH_TOKEN_ENC_KEY(S) is not set. Set REFRESH_TOKEN_ENC_KEYS or REFRESH_TOKEN_ENC_KEY to enable secure refresh token encryption.'); +} +// First entry is the current key +const CURRENT_KEY_ID = ENC_KEYS[0].id; +const CURRENT_KEY = ENC_KEYS[0].key; + +function encryptToken(plain: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENC_ALGO, CURRENT_KEY, iv); + const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // Store as keyId:base64(iv|tag|ciphertext) + const blob = Buffer.concat([iv, tag, encrypted]).toString('base64'); + return `${CURRENT_KEY_ID}:${blob}`; +} + +// Attempt to decrypt; supports legacy blobs (no prefix) by trying all known keys +function decryptToken(blobWithOptionalPrefix: string): { value: string; keyId: string } { + // If prefixed with keyId:, split + let keyId: string | null = null; + let blobB64 = blobWithOptionalPrefix; + const m = blobWithOptionalPrefix.match(/^([A-Za-z0-9_-]+):(.+)$/); + if (m) { + keyId = m[1]; + blobB64 = m[2]; + } + + const buf = Buffer.from(blobB64, 'base64'); + if (buf.length < 28) throw new Error('Invalid encrypted blob'); + + const iv = buf.slice(0, 12); + const tag = buf.slice(12, 28); + const ciphertext = buf.slice(28); + + const tryKeys = (keys: { id: string; key: Buffer }[]) => { + for (const k of keys) { + try { + const decipher = crypto.createDecipheriv(ENC_ALGO, k.key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return { value: decrypted.toString('utf8'), keyId: k.id }; + } catch { + // try next + } + } + return null; + }; + + // If we had an explicit keyId, try that first + if (keyId) { + const found = ENC_KEYS.find((k) => k.id === keyId); + if (found) { + const out = tryKeys([found]); + if (out) return out; + throw new Error('Failed to decrypt with specified key id'); + } + // unknown key id; fall back to trying all keys + } + + const out = tryKeys(ENC_KEYS.map((k) => ({ id: k.id, key: k.key }))); + if (!out) throw new Error('Failed to decrypt refresh token with any known key'); + return out; +} + +const COOKIE_NAME = 'fitbuddyai_sid'; +// Cookie lifetime for the HttpOnly session cookie (in seconds). Centralized +// here for maintainability and to keep behavior consistent across handlers. +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days + +// Typed shape for rows in `fitbuddyai_refresh_tokens` table +type RefreshTokenRow = { + session_id: string; + user_id: string; + refresh_token: string; + created_at: string; + last_used?: string | null; + revoked?: boolean | null; + expires_at?: string | null; +}; + +function parseCookies(cookieHeader: string | undefined): Record { + const out: Record = {}; + if (!cookieHeader) return out; + const parts = cookieHeader.split(';'); + for (const p of parts) { + const [rawKey, ...rest] = p.split('='); + if (!rawKey) continue; + const key = rawKey.trim(); + if (!key) continue; + + const rawValue = (rest || []).join('='); + if (rawValue === undefined) { + // No value provided at all; treat as empty string + out[key] = ''; + continue; + } + + const trimmedValue = rawValue.trim(); + if (!trimmedValue) { + // Empty or whitespace-only value; normalize to empty string + out[key] = ''; + continue; + } + + try { + out[key] = decodeURIComponent(trimmedValue); + } catch { + // If decoding fails due to malformed percent-encoding, fall back to the raw trimmed value + out[key] = trimmedValue; + } + } + return out; +} + +function makeSessionId() { + return uuidv4(); +} + const normalizeEmail = (value: string | undefined | null): string => { if (!value) return ''; return String(value).trim().toLowerCase(); }; +// Admin JWT payload shape and helper moved to module-level to avoid recreating +// the function on every request. Call `requireAdmin(req)` from route handlers. +interface AdminJwtPayload { + role: string; + [key: string]: unknown; +} + +export async function requireAdmin(req: any): Promise { + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret) { + // Fail fast if JWT_SECRET is not set + console.error('[api/auth/index] Missing JWT_SECRET in environment; admin actions are disabled'); + return null; + } + const authHeader = String(req.headers['authorization'] || req.headers['Authorization'] || ''); + const match = authHeader.match(/^Bearer\s+(.+)$/i); + if (!match) return null; + const token = match[1]; + try { + const decoded = jwt.verify(token, jwtSecret) as string | AdminJwtPayload; + if ( + typeof decoded === 'object' && + decoded !== null && + 'role' in decoded && + (decoded as AdminJwtPayload).role && + ((decoded as AdminJwtPayload).role === 'service' || (decoded as AdminJwtPayload).role === 'admin') + ) { + return decoded as AdminJwtPayload; + } + return null; + } catch { + return null; + } +} + export default async function handler(req: any, res: any) { // Set CORS headers for Vercel / browser requests res.setHeader('Access-Control-Allow-Origin', process.env.ALLOW_ORIGIN || '*'); @@ -56,6 +243,199 @@ export default async function handler(req: any, res: any) { } return res.json({ user: safeUser, token }); } + + // Store a refresh token server-side (persist in DB) and issue an HttpOnly session cookie. + if (action === 'store_refresh') { + const { userId, refresh_token } = req.body as { userId?: string; refresh_token?: string }; + if (!userId || !refresh_token) return res.status(400).json({ message: 'userId and refresh_token required.' }); + + // Best-effort: revoke existing sessions for this user to avoid unbounded + // growth of the refresh token table. This keeps only the new session + // active. Do not fail the request if revocation cannot be performed. + try { + const { error: revokeErr } = await supabase.from('fitbuddyai_refresh_tokens').update({ revoked: true }).eq('user_id', userId).neq('revoked', true); + if (revokeErr) { + console.warn('[api/auth/store_refresh] failed to revoke existing sessions for user', userId, revokeErr); + } + } catch (e) { + console.warn('[api/auth/store_refresh] error revoking existing sessions', e); + } + + // Retry insert up to 3 times in case of rare session_id collision + let sid: string | undefined; + let insertErr: unknown = null; + let attempt = 0; + let inserted = false; + const enc = encryptToken(String(refresh_token)); + while (attempt < 3) { + sid = makeSessionId(); + const { error } = await supabase.from('fitbuddyai_refresh_tokens').insert([{ + session_id: sid, + user_id: userId, + refresh_token: enc, + created_at: new Date().toISOString(), + last_used: new Date().toISOString(), + revoked: false + }]); + if (!error) { + insertErr = null; + inserted = true; + break; + } + // Check for unique violation (Supabase/Postgres error code '23505') + if (error.code === '23505' || (error.message && error.message.includes('duplicate key value'))) { + attempt++; + continue; // Try again with a new session_id + } else { + insertErr = error; + break; + } + } + if (insertErr) { + console.error('[api/auth/store_refresh] db insert failed', insertErr); + return res.status(500).json({ message: 'Failed to persist refresh token' }); + } + if (!inserted) { + console.error('[api/auth/store_refresh] failed to insert refresh token after retries'); + return res.status(500).json({ message: 'Failed to persist refresh token' }); + } + if (!sid) { + return res.status(500).json({ message: 'Could not generate unique session_id' }); + } + // Set cookie (HttpOnly). In production, set Secure flag; use SameSite=Lax for compatibility with OAuth/email links. + const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : ''; + res.setHeader('Set-Cookie', `${COOKIE_NAME}=${sid}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE_SECONDS}${secureFlag}`); + return res.json({ ok: true, session_id: sid }); + } + + // Refresh access token using stored server-side refresh token (lookup by cookie) + if (action === 'refresh') { + try { + const cookies = parseCookies(req.headers?.cookie as string | undefined); + const sid = cookies[COOKIE_NAME]; + if (!sid) return res.status(401).json({ message: 'No session cookie present' }); + // Lookup DB row + const { data: rows, error: selErr } = await supabase.from('fitbuddyai_refresh_tokens').select('*').eq('session_id', sid).limit(1).maybeSingle(); + if (selErr) { + console.error('[api/auth/refresh] db select error', selErr); + return res.status(500).json({ message: 'Failed to lookup session' }); + } + const entry = (rows as RefreshTokenRow | null); + if (!entry || entry.revoked) return res.status(401).json({ message: 'Session not found or revoked' }); + // Optionally check expiry if expires_at present + if (entry.expires_at && new Date(entry.expires_at) < new Date()) { + return res.status(401).json({ message: 'Session expired' }); + } + // Decrypt the stored refresh token and call Supabase token endpoint to exchange it for a new access token + let decryptedRefresh = ''; + try { + const dec = decryptToken(String(entry.refresh_token)); + decryptedRefresh = dec.value; + // If token was encrypted with an old key, rotate by re-encrypting + if (dec.keyId !== CURRENT_KEY_ID) { + try { + const newEnc = encryptToken(decryptedRefresh); + await supabase.from('fitbuddyai_refresh_tokens').update({ refresh_token: newEnc, last_used: new Date().toISOString() }).eq('session_id', sid); + } catch (e) { + console.warn('[api/auth/refresh] failed to rotate refresh token to new key', e); + } + } + } catch (e) { + console.error('[api/auth/refresh] failed to decrypt refresh token', e); + // Mark revoked to be safe + try { await supabase.from('fitbuddyai_refresh_tokens').update({ revoked: true }).eq('session_id', sid); } catch {} + return res.status(401).json({ message: 'Invalid session' }); + } + const tokenUrl = `${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`; + const resp = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: SUPABASE_KEY || '', + Authorization: `Bearer ${SUPABASE_KEY || ''}` + }, + body: JSON.stringify({ refresh_token: decryptedRefresh }) + }); + const body = await resp.json(); + if (!resp.ok) { + console.warn('[api/auth/refresh] supabase token refresh failed', body); + // If refresh failed due to invalid token, mark revoked + try { await supabase.from('fitbuddyai_refresh_tokens').update({ revoked: true }).eq('session_id', sid); } catch {} + return res.status(401).json({ message: 'Failed to refresh token' }); + } + // Update last_used and rotate refresh_token if provided + try { + const updates: { last_used: string; refresh_token?: string } = { last_used: new Date().toISOString() }; + if (body.refresh_token) updates.refresh_token = encryptToken(body.refresh_token); + await supabase.from('fitbuddyai_refresh_tokens').update(updates).eq('session_id', sid); + } catch (e) { + console.warn('[api/auth/refresh] failed to update refresh token record', e); + } + // Do NOT return the refresh_token to the client. Return access_token and expiry only. + return res.json({ access_token: body.access_token, expires_at: body.expires_at ?? body.expires_in }); + } catch (e) { + console.error('[api/auth/refresh] error', e); + return res.status(500).json({ message: 'Refresh failed' }); + } + } + + // Clear stored refresh token and instruct browser to clear cookie + if (action === 'clear_refresh') { + try { + const cookies = parseCookies(req.headers?.cookie as string | undefined); + const sid = cookies[COOKIE_NAME]; + if (sid) { + await supabase.from('fitbuddyai_refresh_tokens').update({ revoked: true }).eq('session_id', sid); + } + // Clear cookie + res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`); + return res.json({ ok: true }); + } catch { + return res.status(500).json({ message: 'Failed to clear refresh session' }); + } + } + + // Admin endpoints replaced with JWT-based admin auth. Verify incoming + // Authorization: Bearer where is a JWT signed by your + // server's JWT secret and includes claim `role: 'service'` or `role: 'admin'.` + + + + if (action === 'revoke_session') { + const admin = await requireAdmin(req); + if (!admin) return res.status(403).json({ message: 'Forbidden' }); + const { session_id } = req.body as { session_id?: string }; + if (!session_id) return res.status(400).json({ message: 'session_id required' }); + const { error: revErr } = await supabase.from('fitbuddyai_refresh_tokens').update({ revoked: true }).eq('session_id', session_id); + if (revErr) return res.status(500).json({ message: 'Failed to revoke session' }); + return res.json({ ok: true }); + } + + if (action === 'revoke_user_sessions') { + const admin = await requireAdmin(req); + if (!admin) return res.status(403).json({ message: 'Forbidden' }); + const { userId } = req.body as { userId?: string }; + if (!userId) return res.status(400).json({ message: 'userId required' }); + const { error: revErr } = await supabase.from('fitbuddyai_refresh_tokens').update({ revoked: true }).eq('user_id', userId); + if (revErr) return res.status(500).json({ message: 'Failed to revoke sessions for user' }); + return res.json({ ok: true }); + } + + if (action === 'cleanup_refresh_tokens') { + const admin = await requireAdmin(req); + if (!admin) return res.status(403).json({ message: 'Forbidden' }); + // Delete or mark as revoked entries older than N days (default 30) + const days = Number(req.body?.days || 30); + try { + const threshold = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const { error: delErr } = await supabase.from('fitbuddyai_refresh_tokens').delete().lt('created_at', threshold); + if (delErr) return res.status(500).json({ message: 'Cleanup failed' }); + return res.json({ ok: true }); + } catch (e) { + console.error('[api/auth/index] Error during cleanup_refresh_tokens', e); + return res.status(500).json({ message: 'Cleanup failed' }); + } + } if (action === 'signup') { const { email, username, password } = req.body as { email: string; username: string; password: string }; diff --git a/scripts/cleanup_refresh_tokens.js b/scripts/cleanup_refresh_tokens.js new file mode 100644 index 0000000..439bbcd --- /dev/null +++ b/scripts/cleanup_refresh_tokens.js @@ -0,0 +1,91 @@ +/* Cleanup script: call Supabase RPC cleanup_old_refresh_tokens or use REST endpoint + Usage: node scripts/cleanup_refresh_tokens.js [days] + Requires env: SUPABASE_URL, SUPABASE_KEY (service role) +*/ +const { createClient } = require('@supabase/supabase-js'); + +const days = Number(process.argv[2] || 30); +const SUPABASE_URL = process.env.SUPABASE_URL; +const SUPABASE_KEY = process.env.SUPABASE_KEY; +if (!SUPABASE_URL || !SUPABASE_KEY) { + console.error('SUPABASE_URL and SUPABASE_KEY must be set'); + process.exit(1); +} +const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); + +async function run() { + console.log('Running cleanup_old_refresh_tokens for', days, 'days'); + try { + // If you added the plpgsql function cleanup_old_refresh_tokens, call it via RPC + const { data, error } = await supabase.rpc('cleanup_old_refresh_tokens', { days }); + if (error) { + console.error('RPC cleanup error', error); + // Fallback: attempt to replicate the SQL cleanup logic via REST: + // 1) delete expired tokens immediately (expires_at < now) + // 2) delete revoked tokens older than revoked_retention_days (default 1) + // 3) delete non-revoked tokens older than `days` (and not already expired) + const revokedRetentionDays = Number(process.argv[3] || 1); + const nowIso = new Date().toISOString(); + const thresholdDays = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const thresholdRevoked = new Date(Date.now() - revokedRetentionDays * 24 * 60 * 60 * 1000).toISOString(); + + // 1) delete expired tokens + const { error: delExpiredErr } = await supabase + .from('fitbuddyai_refresh_tokens') + .delete() + .lt('expires_at', nowIso) + .not('expires_at', 'is', null); + if (delExpiredErr) { + console.error('Fallback delete (expired tokens) failed', delExpiredErr); + process.exit(2); + } + + // 2) delete revoked tokens older than revoked_retention_days + const { error: delRevokedErr } = await supabase + .from('fitbuddyai_refresh_tokens') + .delete() + .eq('revoked', true) + .lt('created_at', thresholdRevoked); + if (delRevokedErr) { + console.error('Fallback delete (revoked tokens) failed', delRevokedErr); + process.exit(2); + } + + // 3) delete non-revoked tokens older than `days` but exclude already-expired tokens + // 3a) delete non-revoked, non-expiring tokens (expires_at IS NULL) + const { error: delOldNullErr } = await supabase + .from('fitbuddyai_refresh_tokens') + .delete() + .eq('revoked', false) + .lt('created_at', thresholdDays) + .is('expires_at', null); + if (delOldNullErr) { + console.error('Fallback delete (old non-revoked, non-expiring tokens) failed', delOldNullErr); + process.exit(2); + } + + // 3b) delete non-revoked tokens older than `days` that have not yet expired (expires_at >= now) + const { error: delOldGteErr } = await supabase + .from('fitbuddyai_refresh_tokens') + .delete() + .eq('revoked', false) + .lt('created_at', thresholdDays) + .gte('expires_at', nowIso); + if (delOldGteErr) { + console.error('Fallback delete (old non-revoked tokens with future expiry) failed', delOldGteErr); + process.exit(2); + } + + console.log('Fallback cleanup succeeded'); + process.exit(0); + } + console.log('Cleanup RPC result:', data); + } catch (e) { + console.error('Cleanup failed', e); + process.exit(2); + } +} +run().catch((err) => { + console.error('Unexpected error in cleanup script', err); + process.exit(2); +}); diff --git a/sql/migrations/002_create_refresh_tokens.sql b/sql/migrations/002_create_refresh_tokens.sql new file mode 100644 index 0000000..f1263bb --- /dev/null +++ b/sql/migrations/002_create_refresh_tokens.sql @@ -0,0 +1,52 @@ +-- Migration 002: create fitbuddyai_refresh_tokens table +-- Idempotent: safe to re-run + +-- Ensure pgcrypto is available for gen_random_uuid() +create extension if not exists pgcrypto; + +create table if not exists fitbuddyai_refresh_tokens ( + id uuid default gen_random_uuid() primary key, + session_id text not null unique, + user_id text not null, + refresh_token text not null, + created_at timestamptz default now(), + last_used timestamptz default now(), + expires_at timestamptz, + revoked boolean default false +); + +create index if not exists idx_fitbuddyai_refresh_tokens_user_id on fitbuddyai_refresh_tokens(user_id); +create index if not exists idx_fitbuddyai_refresh_tokens_created_at on fitbuddyai_refresh_tokens(created_at); + +-- Cleanup function: remove revoked tokens after a short window (default 1 day), and expired tokens after given days +create or replace function cleanup_old_refresh_tokens(days integer default 30, revoked_retention_days integer default 1) +returns void language plpgsql as $$ +begin + -- Delete revoked tokens older than revoked_retention_days + delete from fitbuddyai_refresh_tokens + where revoked = true + and created_at < now() - (revoked_retention_days || ' days')::interval; + + -- Delete expired tokens immediately when they expire (regardless of creation time) + delete from fitbuddyai_refresh_tokens + where expires_at is not null + and expires_at < now(); + + -- Delete non-revoked tokens older than the configured retention `days`. + -- This keeps recently-expired tokens (handled above) from being double-checked + -- and ensures the `days` parameter applies as a retention window for still-valid + -- or non-expiring tokens rather than gating deletion of already-expired tokens. + delete from fitbuddyai_refresh_tokens + where revoked = false + and created_at < now() - (days || ' days')::interval + and (expires_at is null or expires_at >= now()); +end; +$$; + +-- Rotation helper: mark all tokens for a user as revoked (useful when issuing a new token) +create or replace function revoke_user_refresh_tokens(p_user_id text) +returns void language plpgsql as $$ +begin + update fitbuddyai_refresh_tokens set revoked = true where user_id = p_user_id; +end; +$$; diff --git a/sql/migrations/003_refresh_tokens_rls.sql b/sql/migrations/003_refresh_tokens_rls.sql new file mode 100644 index 0000000..4ab0ef7 --- /dev/null +++ b/sql/migrations/003_refresh_tokens_rls.sql @@ -0,0 +1,32 @@ +-- Migration 003: RLS policies for fitbuddyai_refresh_tokens +-- Ensure table exists (created in previous migration) + +-- Enable Row Level Security +ALTER TABLE IF EXISTS fitbuddyai_refresh_tokens ENABLE ROW LEVEL SECURITY; + +-- Only allow server/service role to insert/select/update/delete rows. +-- This assumes your service role is represented in JWT claim `role` as 'service' or you can adapt to 'admin'. +DROP POLICY IF EXISTS refresh_tokens_service_only ON fitbuddyai_refresh_tokens; +CREATE POLICY refresh_tokens_service_only + ON fitbuddyai_refresh_tokens + FOR ALL + USING (true) + WITH CHECK (true); + +-- In addition, create a limited policy to allow a user to revoke their own session via a secure server endpoint +-- (server endpoints should use service role, so this remains conservative). If you want to allow users to see their own sessions +-- via JWT with their user id, uncomment and adapt the policy below. +-- +-- DROP POLICY IF EXISTS refresh_tokens_owner_or_service ON fitbuddyai_refresh_tokens; +-- CREATE POLICY refresh_tokens_owner_or_service +-- ON fitbuddyai_refresh_tokens +-- FOR SELECT +-- USING (current_setting('jwt.claims.sub', true) = user_id OR current_setting('jwt.claims.role', true) = 'service'); + +-- Indexes (if not already created) +CREATE INDEX IF NOT EXISTS idx_fitbuddyai_refresh_tokens_user_id ON fitbuddyai_refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_fitbuddyai_refresh_tokens_created_at ON fitbuddyai_refresh_tokens(created_at); + +-- Notes: +-- To call the table from server-side code, use your Supabase service role key so that RLS allows the operation. +-- Keep the service role key strictly server-side and never expose it to clients. diff --git a/src/App.tsx b/src/App.tsx index fd85a6c..612355e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,7 @@ import EmailVerifyPage from './components/EmailVerifyPage'; import { WorkoutPlan, DayWorkout, Exercise } from './types'; -import { loadUserData, loadWorkoutPlan, saveUserData, saveWorkoutPlan, clearUserData, getAuthToken, loadSupabaseSession, saveSupabaseSession, clearSupabaseSession, saveAuthToken } from './services/localStorage'; +import { loadUserData, loadWorkoutPlan, saveUserData, saveWorkoutPlan, clearUserData, getAuthToken, saveAuthToken } from './services/localStorage'; import { fetchUserById } from './services/authService'; import { format } from 'date-fns'; import { getPrimaryType, isWorkoutCompleteForStreak, resolveWorkoutTypes } from './utils/streakUtils'; @@ -33,6 +33,7 @@ import AdminPage from './components/AdminAuditPage'; import { useCloudBackup } from './hooks/useCloudBackup'; import RickrollPage from './components/RickrollPage'; import BlogPage from './components/BlogPage'; +import BlogListPage from './components/BlogListPage'; import AgreementBanner from './components/AgreementBanner'; import TermsPage from './components/TermsPage'; import PrivacyPage from './components/PrivacyPage'; @@ -71,12 +72,21 @@ function App() { if (!useSupabase) return; const { data: authListener } = supabase.auth.onAuthStateChange((_, session) => { if (session) { - try { saveSupabaseSession(session); } catch {} + // When Supabase client receives a session, send the refresh token + // to the server for safe server-side storage and set an HttpOnly cookie. + try { + fetch('/api/auth?action=store_refresh', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: session.user?.id, refresh_token: session.refresh_token }) + }).catch(() => {}); + } catch (e) {} if (session.access_token) { try { saveAuthToken(session.access_token); } catch {} } } else { - try { clearSupabaseSession(); } catch {} + try { fetch('/api/auth?action=clear_refresh', { method: 'POST', credentials: 'include' }).catch(() => {}); } catch {} } }); return () => { @@ -260,19 +270,35 @@ function App() { (async () => { if (useSupabase) { try { - const storedSession = loadSupabaseSession(); - if (storedSession?.access_token && storedSession?.refresh_token) { - await supabase.auth.setSession({ - access_token: storedSession.access_token, - refresh_token: storedSession.refresh_token - }); - if (storedSession.access_token) { - try { saveAuthToken(storedSession.access_token); } catch {} + // Attempt to refresh the access token via server-side stored refresh token. + const resp = await fetch('/api/auth?action=refresh', { + method: 'POST', + credentials: 'include', + }); + if (resp.ok) { + const data = await resp.json(); + if (data?.access_token) { + try { saveAuthToken(data.access_token); } catch {} + // Set a session on the Supabase client so client-side SDK calls + // work until the server refreshes again. Include `refresh_token` + // only when the server returned one; the Supabase client also + // accepts being seeded with just an `access_token` when a + // refresh token isn't available. + if (data.refresh_token) { + try { + await supabase.auth.setSession({ + access_token: data.access_token, + refresh_token: data.refresh_token, + }); + } catch (e) { + // Swallow Supabase client errors here; app can still rely on + // the stored access token for server-side operations. + } + } } } } catch (err) { - console.warn('[App] Supabase session restore failed', err); - try { clearSupabaseSession(); } catch {} + console.warn('[App] Supabase server-side refresh failed', err); } } const savedUserData = loadUserData(); @@ -557,7 +583,8 @@ function App() { } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/AgreementBanner.tsx b/src/components/AgreementBanner.tsx index 9c01a6a..51dee07 100644 --- a/src/components/AgreementBanner.tsx +++ b/src/components/AgreementBanner.tsx @@ -10,7 +10,7 @@ export default function AgreementBanner({ userData }: Props) { useEffect(() => { // If the user just signed in, migrate any anonymous acceptances into their record - if (userData?.id) { try { migrateAnonToUser(userData.id); } catch (e) {} } + if (userData?.id) { try { migrateAnonToUser(userData.id); } catch {} } const checkLocalThenServer = async () => { try { // local quick check diff --git a/src/components/BlogListPage.css b/src/components/BlogListPage.css new file mode 100644 index 0000000..c424209 --- /dev/null +++ b/src/components/BlogListPage.css @@ -0,0 +1,221 @@ +.blog-list-root { + max-width: 1200px; + margin: 32px auto 80px; + padding: 0 16px; + color: var(--text-dark); +} + +.blog-hero { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 16px; + background: linear-gradient(135deg, rgba(88, 204, 2, 0.12), rgba(28, 176, 246, 0.14)); + border: 1px dashed var(--border-light); + overflow: hidden; + position: relative; +} + +.blog-hero::after { + content: ''; + position: absolute; + inset: -10% 40% auto auto; + width: 320px; + height: 320px; + background: radial-gradient(circle, rgba(28, 176, 246, 0.18) 0%, rgba(28, 176, 246, 0) 60%); + transform: rotate(-12deg); + pointer-events: none; +} + +.hero-left { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 12px; +} + +.hero-eyebrow { + text-transform: uppercase; + letter-spacing: 1px; + font-size: 12px; + color: var(--text-medium); + margin: 0; +} + +.hero-left h1 { + font-size: 30px; + line-height: 1.2; + margin: 0; +} + +.hero-dek { + margin: 0; + color: var(--text-medium); + max-width: 620px; +} + +.hero-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.hero-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px 16px; + border-radius: 12px; + text-decoration: none; + font-weight: 700; + border: 1px solid var(--border-light); + color: var(--text-dark); + background: var(--background-white); +} + +.hero-btn.primary { + background: linear-gradient(135deg, var(--accent, var(--primary-green)), #1cb0f6); + color: #fff; + border: none; + box-shadow: 0 10px 30px var(--shadow-light); +} + +.hero-btn.ghost { + background: rgba(28, 176, 246, 0.1); +} + +.hero-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + color: var(--text-medium); + font-size: 14px; +} + +.featured-card { + position: relative; + z-index: 1; + padding: 18px; + border-radius: 14px; + background: var(--background-white); + border: 1px solid var(--border-light); + text-decoration: none; + color: var(--text-dark); + box-shadow: 0 10px 26px var(--shadow-light); + display: flex; + flex-direction: column; + gap: 10px; +} + +.featured-card h2 { + margin: 0; + font-size: 22px; + line-height: 1.25; +} + +.featured-card p { + margin: 0; + color: var(--text-medium); +} + +.featured-top { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.badge { + background: var(--masthead-gradient, var(--gradient-primary)); + color: #fff; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.pill { + padding: 6px 10px; + border-radius: 999px; + background: rgba(28, 176, 246, 0.12); + color: var(--text-medium); + font-size: 12px; +} + +.featured-footer { + display: flex; + gap: 8px; + align-items: center; + color: var(--text-medium); + font-size: 14px; +} + +.blog-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin-top: 18px; +} + +.blog-card { + padding: 16px; + border-radius: 16px; + border: 1px solid var(--border-light); + background: var(--background-white); + text-decoration: none; + color: var(--text-dark); + box-shadow: 0 8px 24px var(--shadow-light); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.blog-card.with-glow { + border-color: var(--accent, var(--primary-green)); +} + +.blog-card:hover { + transform: translateY(-4px); + box-shadow: 0 14px 32px var(--shadow-light); + border-color: var(--accent, var(--primary-green)); +} + +.card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.tag { + font-size: 12px; + padding: 6px 10px; + border-radius: 10px; + background: rgba(28, 176, 246, 0.1); + color: var(--text-medium); +} + +.blog-card h3 { + margin: 0 0 8px 0; + font-size: 18px; + line-height: 1.3; +} + +.blog-card p { + margin: 0 0 12px 0; + color: var(--text-medium); +} + +.card-meta { + display: flex; + gap: 8px; + align-items: center; + font-size: 14px; + color: var(--text-medium); +} + +@media (max-width: 960px) { + .blog-hero { + grid-template-columns: 1fr; + } +} diff --git a/src/components/BlogListPage.tsx b/src/components/BlogListPage.tsx new file mode 100644 index 0000000..6e150cd --- /dev/null +++ b/src/components/BlogListPage.tsx @@ -0,0 +1,85 @@ +import { FC } from 'react'; +import { Link } from 'react-router-dom'; +import './BlogListPage.css'; +import './generatedBlogVars.css'; +import { blogPosts } from '../data/blogPosts'; + +const BlogListPage: FC = () => { + const featured = blogPosts[0]; + if (!featured) return null; + const rest = blogPosts.slice(1); + const surprise = rest[0] || featured; + const pageClass = `blog-list-vars-${featured.slug.replace(/[^a-z0-9-_]/gi, '-')}`; + + return ( +
+
+
+

FitBuddy blog

+

Stories, tips, and lab notes for people building their streak.

+

+ Dispatches from the team plus practical playbooks you can use today. Fresh ink, + no fluff. +

+
+ + Read the latest + + + Surprise me + +
+
+ {featured.date} + | + {featured.readTime} + | + {featured.tags.slice(0, 2).join(' | ')} +
+
+ + +
+ {featured.heroBadge || 'Dispatch'} + {featured.heroNote || 'Latest drop'} +
+

{featured.title}

+

{featured.dek}

+
+ {featured.date} + | {featured.readTime} +
+ +
+ +
+ {blogPosts.map((post) => ( + +
+ {post.heroBadge || 'Story'} + {post.tags[0]} +
+

{post.title}

+

{post.dek}

+
+ {post.date} + | + {post.readTime} +
+ + ))} +
+
+ ); +}; + +export default BlogListPage; diff --git a/src/components/BlogPage.css b/src/components/BlogPage.css index cac4109..4fa9162 100644 --- a/src/components/BlogPage.css +++ b/src/components/BlogPage.css @@ -1,49 +1,353 @@ .fb-news-root { - max-width: 980px; - margin: 36px auto; - padding: 0 12px; + max-width: 1100px; + margin: 40px auto; + padding: 0 16px 72px; color: var(--text-dark); } -.fb-masthead{ - display:flex; - gap:16px; - align-items:center; - padding:18px 20px; - border-radius: 12px 12px 0 0; - background: var(--gradient-primary); + +.blog-nav { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.pill-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--border-light); + text-decoration: none; + color: var(--text-dark); + background: var(--background-white); + font-weight: 600; + box-shadow: 0 4px 14px var(--shadow-light); +} + +.pill-link:hover { + border-color: var(--accent, var(--primary-green)); +} + +.pill-link.soft { + background: rgba(30, 144, 203, 0.08); + color: var(--text-medium); + border-style: dashed; +} + +.fb-masthead { + display: flex; + gap: 18px; + align-items: flex-start; + padding: 20px; + border-radius: 16px 16px 0 0; + background: var(--masthead-gradient, var(--gradient-primary)); color: #fff; box-shadow: 0 6px 20px var(--shadow-light); } -.masthead-left{display:flex;align-items:center;justify-content:center;width:72px;height:72px;background:rgba(255,255,255,0.08);border-radius:12px} -.newspaper-logo{font-size:36px;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.25))} -.paper-title{ + +.masthead-left { + display: flex; + align-items: center; + justify-content: center; + width: 82px; + height: 82px; + background: rgba(255, 255, 255, 0.08); + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.newspaper-logo { + font-size: 26px; + font-weight: 800; + letter-spacing: 1px; + text-transform: uppercase; +} + +.paper-eyebrow { + font-size: 12px; + letter-spacing: 1px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 6px; +} + +.paper-title { font-family: 'Georgia', 'Times New Roman', serif; - margin:0; - font-size:34px; - line-height:1.05; - font-weight:700; -} -.paper-sub{color:rgba(255,255,255,0.9);font-size:13px} -.fb-paper{padding:22px;background:var(--background-white);border-radius:0 0 12px 12px;margin-top:-6px} -.lead{border-bottom:1px dashed var(--border-light);padding-bottom:12px;margin-bottom:14px} -.lead .hero{display:flex;gap:16px;align-items:center} -.mascot{width:88px;height:88px;flex:0 0 88px;border-radius:12px} -.lead-title{font-family:'Georgia',serif;margin:0 0 8px 0;font-size:20px} -.lead-dek{color:var(--text-medium);margin:0} -.lead-text{max-width:760px} -.story{column-count:2;column-gap:32px;font-size:15px;line-height:1.7;color:var(--text-dark)} -.story p{margin:0 0 14px} -.encouragement{break-inside:avoid-column;margin-top:14px;padding:14px;background:linear-gradient(90deg, rgba(30,203,123,0.06), rgba(30,144,203,0.03));border-radius:10px;border:1px solid var(--border-light)} -.encouragement p{margin:8px 0 0} -.story-foot{column-span:all;margin-top:18px;display:flex;align-items:center;justify-content:space-between;gap:12px} -.byline{font-size:13px;color:var(--text-medium);margin:0} -.story-actions{display:flex;gap:12px;align-items:center} -.cta{display:inline-block;padding:10px 18px;border-radius:12px;background:var(--color-primary);color:#fff;text-decoration:none;font-weight:600} + margin: 0 0 6px 0; + font-size: 32px; + line-height: 1.15; + font-weight: 700; + color: #fff; +} + +.paper-sub { + margin: 0 0 12px 0; + color: rgba(255, 255, 255, 0.9); +} + +.paper-meta { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + color: rgba(255, 255, 255, 0.82); + font-size: 14px; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.story-tag { + padding: 6px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + font-size: 12px; +} + +.fb-paper { + padding: 24px; + background: var(--background-white); + border-radius: 0 0 16px 16px; + margin-top: -6px; +} + +.lead { + border-bottom: 1px dashed var(--border-light); + padding-bottom: 16px; + margin-bottom: 16px; +} + +.lead .hero { + display: flex; + gap: 16px; + align-items: center; +} + +.mascot { + width: 92px; + height: 92px; + flex: 0 0 92px; + border-radius: 12px; +} + +.lead-text { + flex: 1; +} + +.lead-dek { + color: var(--text-medium); + margin: 0; + font-size: 16px; +} + +.lead-kickers { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} + +.kicker { + background: rgba(30, 144, 203, 0.08); + color: var(--text-dark); + border-radius: 999px; + padding: 6px 12px; + font-size: 12px; + border: 1px solid var(--border-light); +} + +.story { + column-count: 2; + column-gap: 32px; + font-size: 16px; + line-height: 1.7; + color: var(--text-dark); +} + +.story p { + margin: 0 0 16px 0; +} + +.story-list { + break-inside: avoid-column; + margin: 4px 0 16px 0; + padding: 12px; + background: linear-gradient(135deg, rgba(30, 144, 203, 0.06), rgba(30, 203, 123, 0.04)); + border-radius: 12px; + border: 1px solid var(--border-light); +} + +.list-title { + margin: 0 0 8px 0; + font-weight: 700; + color: var(--text-dark); +} + +.story-list ul { + padding-left: 18px; + margin: 0; + display: grid; + gap: 8px; +} + +.story-list li { + color: var(--text-dark); +} + +.encouragement { + break-inside: avoid-column; + margin: 10px 0 18px 0; + padding: 14px; + background: linear-gradient(135deg, rgba(30, 144, 203, 0.08), rgba(88, 204, 2, 0.06)); + border-radius: 12px; + border: 1px solid var(--border-light); +} + +.encouragement p { + margin: 8px 0 0 0; +} + +.story-foot { + column-span: all; + margin-top: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-light); +} + +.byline { + font-size: 14px; + color: var(--text-medium); + margin: 0; +} + +.story-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.cta { + display: inline-block; + padding: 10px 16px; + border-radius: 12px; + background: var(--accent, var(--primary-green)); + color: #fff; + text-decoration: none; + font-weight: 700; + box-shadow: 0 6px 18px var(--shadow-light); +} + +.more-reading { + margin-top: 18px; +} + +.more-reading-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.more-title { + margin: 2px 0 0 0; + font-size: 20px; +} + +.more-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} + +.mini-card { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--border-light); + padding: 16px; + border-radius: 14px; + text-decoration: none; + color: var(--text-dark); + background: var(--background-white); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.mini-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 28px var(--shadow-light); + border-color: var(--accent, var(--primary-green)); +} + +.mini-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.mini-badge { + font-size: 12px; + font-weight: 700; + padding: 6px 10px; + border-radius: 999px; + background: var(--masthead-gradient, var(--gradient-primary)); + color: #fff; +} + +.mini-date { + font-size: 12px; + color: var(--text-medium); +} + +.mini-card h4 { + margin: 0; + font-size: 16px; + line-height: 1.4; +} + +.mini-card p { + margin: 0; + color: var(--text-medium); +} + +.mini-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-medium); +} @media (max-width: 900px) { - .story{column-count:1} - .paper-title{font-size:28px} - .fb-paper{padding:18px} - .lead .hero{flex-direction:row} - .mascot{width:72px;height:72px;flex:0 0 72px} + .fb-news-root { + margin: 28px auto 64px; + } + + .fb-masthead { + flex-direction: column; + border-radius: 16px; + } + + .story { + column-count: 1; + } + + .mascot { + width: 72px; + height: 72px; + flex: 0 0 72px; + } } diff --git a/src/components/BlogPage.tsx b/src/components/BlogPage.tsx index 0e7f5cd..104b917 100644 --- a/src/components/BlogPage.tsx +++ b/src/components/BlogPage.tsx @@ -1,17 +1,64 @@ -import React from 'react'; +import { FC } from 'react'; +import { Link, useParams } from 'react-router-dom'; import './BlogPage.css'; +import './generatedBlogVars.css'; +import { blogPosts, findPostBySlug, latestPost } from '../data/blogPosts'; + +const BlogPage: FC = () => { + const { slug } = useParams<{ slug?: string }>(); + const article = findPostBySlug(slug) ?? latestPost; + const suggested = blogPosts.filter((post) => post.slug !== article.slug).slice(0, 2); + const gradientId = `blog-hero-${article.slug}`; + const scopedClass = `blog-vars-${article.slug.replace(/[^a-z0-9-_]/gi, '-')}`; + + // Validation helpers for colors/gradients to mitigate CSS/XSS risks. + // Only allow common safe color formats (hex, rgb(a), hsl(a)) or CSS var() tokens. + const isSafeCssColor = (v: unknown): v is string => { + if (!v || typeof v !== 'string') return false; + const s = v.trim(); + if (!s) return false; + if (/^var\(--[a-z0-9-_]+\)$/i.test(s)) return true; + if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s)) return true; + if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i.test(s)) return true; + if (/^hsla?\(\s*\d+(?:deg|rad|grad|turn)?\s*,\s*\d+%\s*,\s*\d+%?(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i.test(s)) return true; + return false; + }; + + + + const safeAccentColor = isSafeCssColor(article.accentColor) ? article.accentColor : '#1ecb7b'; -const BlogPage: React.FC = () => { - const today = new Date().toLocaleDateString(); return ( -
+
+
+ + Back to all posts + + {article.heroNote || 'FitBuddy dispatch'} +
+
-
🏋️‍♂️
+
FB
-

The FitBuddy Times

-
Daily fitness & dev dispatch — {today}
+
{article.heroBadge || 'Dispatch'}
+

{article.title}

+
{article.dek}
+
+ {article.author} + | + {article.date} + | + {article.readTime} +
+
+ {article.tags.map((tag) => ( + + {tag} + + ))} +
@@ -26,63 +73,99 @@ const BlogPage: React.FC = () => { aria-hidden="true" > - - + + - + -
-

Three students sprint forward — progress, humor, and curiosity lead the way

-

A light-hearted look at an earnest trio building something that helps people move more — and have fun doing it.

+

{article.dek}

+
+ {article.heroNote || 'Team notes'} + Updated {article.date} +
-

- In a bright corner of the internet, three motivated students — Dakota, William, and Zade — have been tinkering, testing, and - cheering each other on as they shape a playful fitness companion. Their work is part curiosity, part stubborn optimism, and - part careful problem-solving. Today they take another step forward. -

- -

- Dakota has been polishing the user experience, smoothing rough edges until clicks feel like a friendly handshake. William is - hunting bugs with the focus of a cat on a laser pointer — relentless, amused, and usually victorious. Zade brings the glue: - creativity, structure, and the occasional very-good pun that lightens long debug sessions. -

- -

- Progress in small, steady steps has produced something meaningful: a product that encourages movement and learning. Along the - way they've learned to ship often, laugh at odd console errors, and celebrate tiny wins — like a calendar date that finally - renders correctly or an AI reply that doesn't accidentally suggest a dinosaur as a warm-up. -

- -

- This paper encourages Dakota, William, and Zade to keep exploring. Try an experiment, break one thing, fix two. Ask a bold - question of the AI coach. Share your findings and keep the momentum: every small improvement helps someone move a little more. -

- -
- To Dakota, William, & Zade: -

Keep building. Keep testing. Keep laughing. The site is already making a difference — and the best features are still ahead.

-
+ {article.body.map((block, index) => { + if (block.kind === 'paragraph') { + return

{block.text}

; + } + if (block.kind === 'list') { + return ( +
+ {block.title ?

{block.title}

: null} +
    + {block.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); + } + if (block.kind === 'callout') { + return ( +
+ {block.title ? {block.title} : null} +

{block.text}

+
+ ); + } + return null; + })}
-

By The FitBuddy Times — Community Dispatch

+

By {article.author}

- Back to dashboard + + Back to blog +
+ +
+
+
+

Keep reading

+

More from the FitBuddy blog

+
+ + View all + +
+
+ {suggested.map((post) => { + return ( + +
+ {post.heroBadge || 'Story'} + + {post.date} | {post.readTime} + +
+

{post.title}

+

{post.dek}

+
+ {post.tags.slice(0, 2).map((tag) => ( + {tag} + ))} +
+ + ); + })} +
+
); }; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c6aeb1a..7f784e5 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Link } from 'react-router-dom'; import './Footer.css'; import { Dumbbell } from 'lucide-react'; @@ -9,6 +10,29 @@ interface FooterProps { export default function Footer({ themeMode = 'auto', onChangeThemeMode }: FooterProps) { const cur = themeMode || 'auto'; + // Hide footer while intro overlay is active + const [introActive, setIntroActive] = React.useState(false); + React.useEffect(() => { + const onStart = () => setIntroActive(true); + const onEnd = () => setIntroActive(false); + // Initialize from body class in case the event fired before this component mounted + try { + const already = Boolean(document && document.body && document.body.classList && document.body.classList.contains('intro-active')); + if (already) setIntroActive(true); + } catch {} + try { + window.addEventListener('fitbuddyai-intro-start', onStart as EventListener); + window.addEventListener('fitbuddyai-intro-end', onEnd as EventListener); + } catch {} + return () => { + try { + window.removeEventListener('fitbuddyai-intro-start', onStart as EventListener); + window.removeEventListener('fitbuddyai-intro-end', onEnd as EventListener); + } catch {} + }; + }, []); + + if (introActive) return null; return (