feat: standardize header, categories, cross-app spaces

- AppSwitcher: rTube/rSwag → Creating, rSocials → Sharing, rData → Observing
- EcosystemFooter: updated link order to match new categories
- UserMenu: 🔑 Sign In button, 🔐 lock when logged in
- SpaceSwitcher: reads EncryptID token, sends Bearer header
- /api/spaces proxy: forwards to rspace.online (canonical spaces)
- /api/me: verifies EncryptID token for auth status
- Header.tsx: standardized bg-slate-900/85 across all apps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 14:16:56 -08:00
parent cce178671b
commit ede11d8568
3 changed files with 103 additions and 48 deletions

View File

@ -1,29 +1,52 @@
/**
* /api/me Returns current user's auth status.
*
* Checks for EncryptID token in Authorization header or cookie,
* then verifies it against the EncryptID server.
*/
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getAuthUser } from '@/lib/auth';
import { getWorkspaceSlug } from '@/lib/workspace';
export async function GET(request: NextRequest) { const ENCRYPTID_URL = process.env.ENCRYPTID_URL || 'https://auth.ridentity.online';
export async function GET(req: NextRequest) {
// Extract token from Authorization header or cookie
const auth = req.headers.get('Authorization');
let token: string | null = null;
if (auth?.startsWith('Bearer ')) {
token = auth.slice(7);
} else {
const tokenCookie = req.cookies.get('encryptid_token');
if (tokenCookie) token = tokenCookie.value;
}
if (!token) {
return NextResponse.json({ authenticated: false });
}
try { try {
const auth = await getAuthUser(request); const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
const workspaceSlug = getWorkspaceSlug(); headers: { Authorization: `Bearer ${token}` },
});
if (!auth) { if (!res.ok) {
return NextResponse.json({ authenticated: false });
}
const data = await res.json();
if (data.valid) {
return NextResponse.json({ return NextResponse.json({
authenticated: false, authenticated: true,
workspace: workspaceSlug || null, user: {
username: data.username || null,
did: data.did || data.userId || null,
},
}); });
} }
return NextResponse.json({ return NextResponse.json({ authenticated: false });
authenticated: true,
user: {
id: auth.user.id,
username: auth.user.username,
did: auth.did,
},
workspace: workspaceSlug || null,
});
} catch { } catch {
return NextResponse.json({ authenticated: false, workspace: null }); return NextResponse.json({ authenticated: false });
} }
} }

View File

@ -1,34 +1,45 @@
import { NextResponse } from 'next/server';
/** /**
* GET /api/spaces List spaces available to the current user. * Spaces API proxy forwards to rSpace (the canonical spaces authority).
* Proxies to rSpace API when available, otherwise returns empty list. *
* Every r*App proxies /api/spaces to rSpace so the SpaceSwitcher dropdown
* shows the same spaces everywhere. The EncryptID token is forwarded so
* rSpace can return user-specific spaces (owned/member).
*/ */
export async function GET(request: Request) {
const rspaceUrl = process.env.RSPACE_INTERNAL_URL || process.env.NEXT_PUBLIC_RSPACE_URL;
if (!rspaceUrl) { import { NextRequest, NextResponse } from 'next/server';
return NextResponse.json({ spaces: [] });
const RSPACE_API = process.env.RSPACE_API_URL || 'https://rspace.online';
export async function GET(req: NextRequest) {
const headers: Record<string, string> = {};
// Forward the EncryptID token (from Authorization header or cookie)
const auth = req.headers.get('Authorization');
if (auth) {
headers['Authorization'] = auth;
} else {
// Fallback: check for encryptid_token cookie
const tokenCookie = req.cookies.get('encryptid_token');
if (tokenCookie) {
headers['Authorization'] = `Bearer ${tokenCookie.value}`;
}
} }
try { try {
// Forward auth header to rSpace const res = await fetch(`${RSPACE_API}/api/spaces`, {
const authHeader = request.headers.get('Authorization');
const headers: Record<string, string> = {};
if (authHeader) headers['Authorization'] = authHeader;
const res = await fetch(`${rspaceUrl}/api/spaces`, {
headers, headers,
next: { revalidate: 60 }, next: { revalidate: 30 }, // cache for 30s to avoid hammering rSpace
}); });
if (res.ok) { if (!res.ok) {
const data = await res.json(); // If rSpace is down, return empty spaces (graceful degradation)
return NextResponse.json(data); return NextResponse.json({ spaces: [] });
} }
} catch {
// rSpace not reachable
}
return NextResponse.json({ spaces: [] }); const data = await res.json();
return NextResponse.json(data);
} catch {
// rSpace unreachable — return empty list
return NextResponse.json({ spaces: [] });
}
} }

View File

@ -10,10 +10,20 @@ interface SpaceInfo {
} }
interface SpaceSwitcherProps { interface SpaceSwitcherProps {
/** Current app domain, e.g. 'rfunds.online'. Space links become <space>.<domain> */ /** Current app domain, e.g. 'rchats.online'. Space links become <space>.<domain> */
domain?: string; 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;
}
}
export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [spaces, setSpaces] = useState<SpaceInfo[]>([]); const [spaces, setSpaces] = useState<SpaceInfo[]>([]);
@ -38,18 +48,29 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
// Check auth status on mount // Check auth status on mount
useEffect(() => { useEffect(() => {
fetch('/api/me') const token = getEncryptIDToken();
.then((r) => r.json()) if (token) {
.then((data) => { setIsAuthenticated(true);
if (data.authenticated) setIsAuthenticated(true); } else {
}) // Fallback: check /api/me
.catch(() => {}); fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated) setIsAuthenticated(true);
})
.catch(() => {});
}
}, []); }, []);
const loadSpaces = async () => { const loadSpaces = async () => {
if (loaded) return; if (loaded) return;
try { try {
const res = await fetch('/api/spaces'); // Pass EncryptID token so the proxy can forward it to rSpace
const token = getEncryptIDToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('/api/spaces', { headers });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSpaces(data.spaces || []); setSpaces(data.spaces || []);