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:51 -08:00
parent f46575989f
commit 51b9b2f079
3 changed files with 126 additions and 8 deletions

52
src/app/api/me/route.ts Normal file
View File

@ -0,0 +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';
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 {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
return NextResponse.json({ authenticated: false });
}
const data = await res.json();
if (data.valid) {
return NextResponse.json({
authenticated: true,
user: {
username: data.username || null,
did: data.did || data.userId || null,
},
});
}
return NextResponse.json({ authenticated: false });
} catch {
return NextResponse.json({ authenticated: false });
}
}

View File

@ -0,0 +1,45 @@
/**
* Spaces API proxy forwards to rSpace (the canonical spaces authority).
*
* 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).
*/
import { NextRequest, NextResponse } from 'next/server';
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 {
const res = await fetch(`${RSPACE_API}/api/spaces`, {
headers,
next: { revalidate: 30 }, // cache for 30s to avoid hammering rSpace
});
if (!res.ok) {
// If rSpace is down, return empty spaces (graceful degradation)
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 {
/** 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;
}
/** 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) {
const [open, setOpen] = useState(false);
const [spaces, setSpaces] = useState<SpaceInfo[]>([]);
@ -38,18 +48,29 @@ 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();
if (token) {
setIsAuthenticated(true);
} else {
// Fallback: check /api/me
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated) setIsAuthenticated(true);
})
.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<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('/api/spaces', { headers });
if (res.ok) {
const data = await res.json();
setSpaces(data.spaces || []);