From 5d618496816300ff834f39b9ce45be45cfcf5804 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 21:19:44 +0000 Subject: [PATCH] Update production compose and improve app/space switcher components Update docker-compose.prod.yml configuration and enhance AppSwitcher.tsx and SpaceSwitcher.tsx frontend components. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.prod.yml | 99 +++++++++++++++-- frontend/components/AppSwitcher.tsx | 44 ++++++-- frontend/components/SpaceSwitcher.tsx | 146 +++++++++++++++++++++----- 3 files changed, 247 insertions(+), 42 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index afbff7a..9fd2027 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,22 +1,103 @@ +# Production stack β€” use with: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d services: - backend: - volumes: - - /opt/apps/rswag/designs:/app/designs - - /opt/apps/rswag/config:/app/config:ro - - /opt/apps/rswag/spaces:/app/spaces:ro + # PostgreSQL Database + db: + image: postgres:16-alpine + container_name: rswag-db + restart: unless-stopped environment: + POSTGRES_USER: rswag + POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword} + POSTGRES_DB: rswag + volumes: + - rswag-db-data:/var/lib/postgresql/data + networks: + - rswag-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rswag"] + interval: 5s + timeout: 5s + retries: 5 + + # Redis for sessions/cache + redis: + image: redis:7-alpine + container_name: rswag-redis + restart: unless-stopped + volumes: + - rswag-redis-data:/data + networks: + - rswag-internal + + # FastAPI Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: rswag-backend + restart: unless-stopped + environment: + # Infrastructure wiring (not secrets) + - DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag + - REDIS_URL=redis://redis:6379 - DESIGNS_PATH=/app/designs - CONFIG_PATH=/app/config - SPACES_PATH=/app/spaces + - POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true} + - CORS_ORIGINS=${CORS_ORIGINS:-https://rswag.online} + # Infisical injects all other secrets at startup + - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID} + - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET} + - INFISICAL_URL=http://infisical:8080 + - INFISICAL_PROJECT_SLUG=rswag + - INFISICAL_ENV=prod + volumes: + - ./designs:/app/designs + - ./config:/app/config:ro + - ./spaces:/app/spaces:ro + - ./frontend/public/mockups:/app/mockups:ro + depends_on: + db: + condition: service_healthy + networks: + - rswag-internal + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.http.routers.rswag-api.rule=(Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)) && PathPrefix(`/api`)" + - "traefik.http.routers.rswag-api.entrypoints=web" + - "traefik.http.services.rswag-api.loadbalancer.server.port=8000" + - "traefik.docker.network=traefik-public" + # Next.js Frontend frontend: build: + context: ./frontend + dockerfile: Dockerfile args: - - NEXT_PUBLIC_API_URL=https://rswag.online/api + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://rswag.online/api} + container_name: rswag-frontend + restart: unless-stopped + environment: + - NODE_ENV=production + depends_on: + - backend + networks: + - rswag-internal + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.http.routers.rswag-web.rule=Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)" + - "traefik.http.routers.rswag-web.entrypoints=web" + - "traefik.http.services.rswag-web.loadbalancer.server.port=3000" + - "traefik.docker.network=traefik-public" + +volumes: + rswag-db-data: + rswag-redis-data: networks: rswag-internal: driver: bridge - ipam: - config: - - subnet: 10.200.1.0/24 + traefik-public: + external: true diff --git a/frontend/components/AppSwitcher.tsx b/frontend/components/AppSwitcher.tsx index 24f2dcc..3bc9495 100644 --- a/frontend/components/AppSwitcher.tsx +++ b/frontend/components/AppSwitcher.tsx @@ -21,6 +21,7 @@ const MODULES: AppModule[] = [ { id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: 'πŸ‘•', description: 'Community merch & swag store', domain: 'rswag.online' }, // Planning { id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: 'πŸ“…', description: 'Collaborative scheduling & events', domain: 'rcal.online' }, + { id: 'events', name: 'rEvents', badge: 'rEv', color: 'bg-violet-200', emoji: 'πŸŽͺ', description: 'Event aggregation & discovery', domain: 'revents.online' }, { id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' }, { id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: 'πŸ—ΊοΈ', description: 'Collaborative real-time mapping', domain: 'rmaps.online' }, // Communicating @@ -43,6 +44,8 @@ const MODULES: AppModule[] = [ { id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: 'πŸ“’', description: 'Social media management', domain: 'rsocials.online' }, // Observing { id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: 'πŸ“Š', description: 'Analytics & insights dashboard', domain: 'rdata.online' }, + // Learning + { id: 'books', name: 'rBooks', badge: 'rB', color: 'bg-amber-200', emoji: 'πŸ“š', description: 'Collaborative library', domain: 'rbooks.online' }, // Work & Productivity { id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: 'πŸ“‹', description: 'Project & task management', domain: 'rwork.online' }, // Identity & Infrastructure @@ -57,6 +60,7 @@ const MODULE_CATEGORIES: Record = { tube: 'Creating', swag: 'Creating', cal: 'Planning', + events: 'Planning', trips: 'Planning', maps: 'Planning', chats: 'Communicating', @@ -74,6 +78,7 @@ const MODULE_CATEGORIES: Record = { files: 'Sharing', socials: 'Sharing', data: 'Observing', + books: 'Learning', work: 'Work & Productivity', ids: 'Identity & Infrastructure', stack: 'Identity & Infrastructure', @@ -87,10 +92,25 @@ const CATEGORY_ORDER = [ 'Funding & Commerce', 'Sharing', 'Observing', + 'Learning', 'Work & Productivity', 'Identity & Infrastructure', ]; +/** Read the username from the EncryptID session in localStorage */ +function getSessionUsername(): string | null { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem('encryptid_session'); + if (!stored) return null; + const parsed = JSON.parse(stored); + const claims = parsed?.claims || parsed; + return claims?.eid?.username || claims?.username || null; + } catch { + return null; + } +} + /** Build the URL for a module, using username subdomain if logged in */ function getModuleUrl(m: AppModule, username: string | null): string { if (!m.domain) return '#'; @@ -120,16 +140,22 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { return () => document.removeEventListener('click', handleClick); }, []); - // Fetch current user's username for subdomain links + // Read username from EncryptID session in localStorage useEffect(() => { - fetch('/api/me') - .then((r) => r.json()) - .then((data) => { - if (data.authenticated && data.user?.username) { - setUsername(data.user.username); - } - }) - .catch(() => { /* not logged in */ }); + const sessionUsername = getSessionUsername(); + if (sessionUsername) { + setUsername(sessionUsername); + } else { + // Fallback: check /api/me + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated && data.user?.username) { + setUsername(data.user.username); + } + }) + .catch(() => {}); + } }, []); const currentMod = MODULES.find((m) => m.id === current); diff --git a/frontend/components/SpaceSwitcher.tsx b/frontend/components/SpaceSwitcher.tsx index d20fc68..dcc1581 100644 --- a/frontend/components/SpaceSwitcher.tsx +++ b/frontend/components/SpaceSwitcher.tsx @@ -10,15 +10,47 @@ interface SpaceInfo { } interface SpaceSwitcherProps { - /** Current app domain, e.g. 'rswag.online'. Space links become . */ + /** Current app domain, e.g. 'rcal.online'. Space links become . */ domain?: string; } +/** Read the EncryptID token from localStorage (set by token-relay across r*.online) */ +function getEncryptIDToken(): string | null { + if (typeof window === 'undefined') return null; + try { + return localStorage.getItem('encryptid_token'); + } catch { + return null; + } +} + +/** Read the username from the EncryptID session in localStorage */ +function getSessionUsername(): string | null { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem('encryptid_session'); + if (!stored) return null; + const parsed = JSON.parse(stored); + const claims = parsed?.claims || parsed; + return claims?.eid?.username || claims?.username || null; + } catch { + return null; + } +} + +/** Read the current space_id from the cookie set by middleware */ +function getCurrentSpaceId(): string { + if (typeof document === 'undefined') return 'default'; + const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/); + return match ? decodeURIComponent(match[1]) : 'default'; +} + export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { const [open, setOpen] = useState(false); const [spaces, setSpaces] = useState([]); const [loaded, setLoaded] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(null); const ref = useRef(null); // Derive domain from window.location if not provided @@ -26,6 +58,8 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { ? window.location.hostname.split('.').slice(-2).join('.') : 'rspace.online'); + const currentSpaceId = getCurrentSpaceId(); + useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { @@ -38,21 +72,47 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { // Check auth status on mount useEffect(() => { - fetch('/api/me') - .then((r) => r.json()) - .then((data) => { - if (data.authenticated) setIsAuthenticated(true); - }) - .catch(() => {}); + const token = getEncryptIDToken(); + const sessionUsername = getSessionUsername(); + if (token) { + setIsAuthenticated(true); + if (sessionUsername) { + setUsername(sessionUsername); + } + } else { + // Fallback: check /api/me + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated) { + setIsAuthenticated(true); + if (data.user?.username) setUsername(data.user.username); + } + }) + .catch(() => {}); + } }, []); const loadSpaces = async () => { if (loaded) return; try { - const res = await fetch('/api/spaces'); + // Pass EncryptID token so the proxy can forward it to rSpace + const token = getEncryptIDToken(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch('/api/spaces', { headers }); if (res.ok) { const data = await res.json(); - setSpaces(data.spaces || []); + // Handle both flat array and { spaces: [...] } response formats + const raw: Array<{ id?: string; slug?: string; name: string; icon?: string; role?: string }> = + Array.isArray(data) ? data : (data.spaces || []); + setSpaces(raw.map((s) => ({ + slug: s.slug || s.id || '', + name: s.name, + icon: s.icon, + role: s.role, + }))); } } catch { // API not available @@ -71,8 +131,24 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { /** Build URL for a space: . */ const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`; - const mySpaces = spaces.filter((s) => s.role); - const publicSpaces = spaces.filter((s) => !s.role); + // Build personal space entry for logged-in user + const personalSpace: SpaceInfo | null = + isAuthenticated && username + ? { slug: username, name: 'Personal', icon: 'πŸ‘€', role: 'owner' } + : null; + + // Deduplicate: remove personal space from fetched list if it already appears + const dedupedSpaces = personalSpace + ? spaces.filter((s) => s.slug !== personalSpace.slug) + : spaces; + + const mySpaces = dedupedSpaces.filter((s) => s.role); + const publicSpaces = dedupedSpaces.filter((s) => !s.role); + + // Determine what to show in the button + const currentLabel = currentSpaceId === 'default' + ? (personalSpace ? 'personal' : 'public') + : currentSpaceId; return (
@@ -81,7 +157,7 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors" > / - personal + {currentLabel} @@ -89,23 +165,40 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
{!loaded ? (
Loading spaces...
- ) : spaces.length === 0 ? ( + ) : !isAuthenticated && spaces.length === 0 ? ( <>
- {isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'} + Sign in to see your spaces
- setOpen(false)} - > - + Create new space - ) : ( <> + {/* Personal space β€” always first when logged in */} + {personalSpace && ( + <> +
+ Personal +
+ setOpen(false)} + > + {personalSpace.icon} + {username} + + owner + + + + )} + + {/* Other spaces the user belongs to */} {mySpaces.length > 0 && ( <> + {personalSpace &&