rspace-online/lib/rspace-header.ts

920 lines
26 KiB
TypeScript

/**
* rSpace Header Component
*
* Shared header bar with EncryptID sign-in/sign-up for all rSpace pages.
* Reads session from localStorage and provides auth state to the page.
*/
const SESSION_KEY = 'encryptid_session';
const ENCRYPTID_URL = 'https://auth.rspace.online';
interface SessionState {
accessToken: string;
claims: {
sub: string; // user ID
exp: number;
username?: string;
did?: string;
eid: {
authLevel: number;
capabilities: {
encrypt: boolean;
sign: boolean;
wallet: boolean;
};
};
};
}
// ============================================================================
// SESSION HELPERS
// ============================================================================
function getSession(): SessionState | null {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (!stored) return null;
const session = JSON.parse(stored) as SessionState;
const now = Math.floor(Date.now() / 1000);
if (now >= session.claims.exp) {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem('rspace-username');
return null;
}
return session;
} catch {
return null;
}
}
function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem('rspace-username');
}
export function isAuthenticated(): boolean {
return getSession() !== null;
}
export function getAccessToken(): string | null {
return getSession()?.accessToken ?? null;
}
export function getUserDID(): string | null {
return getSession()?.claims.did ?? getSession()?.claims.sub ?? null;
}
export function getUsername(): string | null {
return getSession()?.claims.username ?? null;
}
// ============================================================================
// BASE64URL / JWT HELPERS
// ============================================================================
function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function parseJWT(token: string): Record<string, any> {
const parts = token.split('.');
if (parts.length < 2) return {};
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (b64.length % 4)) % 4);
return JSON.parse(atob(b64 + padding));
} catch {
return {};
}
}
function storeSession(token: string, username: string, did: string): void {
const payload = parseJWT(token);
const session: SessionState = {
accessToken: token,
claims: {
sub: payload.sub || '',
exp: payload.exp || 0,
username,
did,
eid: payload.eid || {
authLevel: 3,
capabilities: { encrypt: true, sign: true, wallet: false },
},
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (username) {
localStorage.setItem('rspace-username', username);
}
}
// ============================================================================
// HEADER STYLES
// ============================================================================
const HEADER_STYLES = `
.rspace-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.rspace-header--dark {
background: rgba(15, 23, 42, 0.85);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.rspace-header--light {
background: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.rspace-header__brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
font-size: 1.25rem;
font-weight: 700;
}
.rspace-header--dark .rspace-header__brand {
color: white;
}
.rspace-header--light .rspace-header__brand {
color: #0f172a;
}
.rspace-header__brand-gradient {
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.rspace-header__right {
display: flex;
align-items: center;
gap: 12px;
}
.rspace-header__signin-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
border-radius: 8px;
border: none;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.rspace-header--dark .rspace-header__signin-btn {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
color: white;
}
.rspace-header--dark .rspace-header__signin-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
}
.rspace-header--light .rspace-header__signin-btn {
background: linear-gradient(135deg, #06b6d4, #0891b2);
color: white;
}
.rspace-header--light .rspace-header__signin-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
}
.rspace-header__user {
display: flex;
align-items: center;
gap: 10px;
position: relative;
cursor: pointer;
}
.rspace-header__avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
color: white;
}
.rspace-header__user-did {
font-size: 0.8rem;
max-width: 140px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rspace-header--dark .rspace-header__user-did {
color: #94a3b8;
}
.rspace-header--light .rspace-header__user-did {
color: #64748b;
}
.rspace-header__dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 200px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
display: none;
}
.rspace-header__dropdown.open {
display: block;
}
.rspace-header--dark .rspace-header__dropdown {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.rspace-header--light .rspace-header__dropdown {
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.rspace-header__dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s;
border: none;
background: none;
width: 100%;
text-align: left;
}
.rspace-header--dark .rspace-header__dropdown-item {
color: #e2e8f0;
}
.rspace-header--dark .rspace-header__dropdown-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.rspace-header--light .rspace-header__dropdown-item {
color: #374151;
}
.rspace-header--light .rspace-header__dropdown-item:hover {
background: #f1f5f9;
}
.rspace-header__dropdown-item--danger {
color: #ef4444 !important;
}
.rspace-header__dropdown-divider {
height: 1px;
margin: 4px 0;
}
.rspace-header--dark .rspace-header__dropdown-divider {
background: rgba(255, 255, 255, 0.08);
}
.rspace-header--light .rspace-header__dropdown-divider {
background: rgba(0, 0, 0, 0.08);
}
/* Auth modal overlay */
.rspace-auth-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s;
}
.rspace-auth-modal {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
max-width: 420px;
width: 90%;
text-align: center;
color: white;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: slideUp 0.3s;
}
.rspace-auth-modal h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.rspace-auth-modal p {
color: #94a3b8;
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 1.5rem;
}
.rspace-auth-modal__input {
width: 100%;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: white;
font-size: 1rem;
margin-bottom: 1rem;
outline: none;
transition: border-color 0.2s;
}
.rspace-auth-modal__input:focus {
border-color: #06b6d4;
}
.rspace-auth-modal__input::placeholder {
color: #64748b;
}
.rspace-auth-modal__actions {
display: flex;
gap: 12px;
margin-top: 0.5rem;
}
.rspace-auth-modal__btn {
flex: 1;
padding: 12px 20px;
border-radius: 8px;
border: none;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.rspace-auth-modal__btn--primary {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
color: white;
}
.rspace-auth-modal__btn--primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
}
.rspace-auth-modal__btn--primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.rspace-auth-modal__btn--secondary {
background: rgba(255, 255, 255, 0.08);
color: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.rspace-auth-modal__btn--secondary:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
}
.rspace-auth-modal__error {
color: #ef4444;
font-size: 0.85rem;
margin-top: 0.5rem;
min-height: 1.2em;
}
.rspace-auth-modal__toggle {
margin-top: 1rem;
font-size: 0.85rem;
color: #64748b;
}
.rspace-auth-modal__toggle a {
color: #06b6d4;
cursor: pointer;
text-decoration: none;
}
.rspace-auth-modal__toggle a:hover {
text-decoration: underline;
}
.rspace-auth-modal__spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`;
// ============================================================================
// AUTH MODAL
// ============================================================================
type AuthMode = 'signin' | 'register';
interface AuthModalCallbacks {
onSuccess: () => void;
onCancel: () => void;
}
let activeModal: HTMLElement | null = null;
let headerRenderFn: (() => void) | null = null;
/**
* Show the EncryptID auth modal for sign-in or registration.
* Uses WebAuthn passkeys via the EncryptID server.
*/
export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
if (activeModal) return;
const overlay = document.createElement('div');
overlay.className = 'rspace-auth-overlay';
let mode: AuthMode = 'signin';
function render() {
overlay.innerHTML = mode === 'signin' ? renderSignIn() : renderRegister();
attachModalListeners();
}
function renderSignIn(): string {
return `
<div class="rspace-auth-modal">
<h2>Sign in with EncryptID</h2>
<p>
Use your passkey to sign in instantly. No passwords needed —
just your fingerprint, face, or device PIN.
</p>
<div class="rspace-auth-modal__actions">
<button class="rspace-auth-modal__btn rspace-auth-modal__btn--secondary" data-action="cancel">Cancel</button>
<button class="rspace-auth-modal__btn rspace-auth-modal__btn--primary" data-action="signin">
🔑 Sign In with Passkey
</button>
</div>
<div class="rspace-auth-modal__error" id="auth-error"></div>
<div class="rspace-auth-modal__toggle">
Don't have an account? <a data-action="switch-register">Create one</a>
</div>
</div>
`;
}
function renderRegister(): string {
return `
<div class="rspace-auth-modal">
<h2>Create your EncryptID</h2>
<p>
Set up a secure, passwordless identity. Your passkey is stored
on your device — we never see your private keys.
</p>
<input
class="rspace-auth-modal__input"
id="auth-username"
type="text"
placeholder="Choose a username"
autocomplete="username webauthn"
maxlength="32"
/>
<div class="rspace-auth-modal__actions">
<button class="rspace-auth-modal__btn rspace-auth-modal__btn--secondary" data-action="cancel">Cancel</button>
<button class="rspace-auth-modal__btn rspace-auth-modal__btn--primary" data-action="register">
🔐 Create Passkey
</button>
</div>
<div class="rspace-auth-modal__error" id="auth-error"></div>
<div class="rspace-auth-modal__toggle">
Already have an account? <a data-action="switch-signin">Sign in</a>
</div>
</div>
`;
}
async function handleSignIn() {
const errorEl = overlay.querySelector('#auth-error') as HTMLElement;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
errorEl.textContent = '';
btn.disabled = true;
btn.innerHTML = '<span class="rspace-auth-modal__spinner"></span> Authenticating...';
try {
// 1. Get server challenge
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions } = await startRes.json();
// 2. WebAuthn ceremony with server challenge
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || 'rspace.online',
userVerification: 'required',
timeout: 60000,
},
}) as PublicKeyCredential;
if (!credential) throw new Error('Authentication failed');
// 3. Complete auth on EncryptID server → get signed JWT + username
const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
},
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) {
throw new Error(data.error || 'Authentication failed');
}
// 4. Store server-signed token with username
storeSession(data.token, data.username || '', data.did || '');
closeModal();
callbacks?.onSuccess?.();
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = '🔑 Sign In with Passkey';
if (err.name === 'NotAllowedError') {
errorEl.textContent = 'Authentication was cancelled or no passkey found.';
} else {
errorEl.textContent = err.message || 'Authentication failed.';
}
}
}
async function handleRegister() {
const usernameInput = overlay.querySelector('#auth-username') as HTMLInputElement;
const errorEl = overlay.querySelector('#auth-error') as HTMLElement;
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
const username = usernameInput.value.trim();
if (!username) {
errorEl.textContent = 'Please enter a username.';
usernameInput.focus();
return;
}
errorEl.textContent = '';
btn.disabled = true;
btn.innerHTML = '<span class="rspace-auth-modal__spinner"></span> Creating passkey...';
try {
// 1. Get registration options from server
const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error('Failed to start registration');
const { options: serverOptions, userId } = await startRes.json();
// 2. WebAuthn ceremony with server challenge
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: {
id: serverOptions.rp?.id || 'rspace.online',
name: serverOptions.rp?.name || 'EncryptID',
},
user: {
id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)),
name: username,
displayName: username,
},
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' as const },
{ alg: -257, type: 'public-key' as const },
],
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
attestation: 'none',
timeout: 60000,
},
}) as PublicKeyCredential;
if (!credential) throw new Error('Failed to create credential');
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
// 3. Complete registration on EncryptID server
const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
},
userId,
username,
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) {
throw new Error(data.error || 'Registration failed');
}
// 4. Store server-signed token with username
storeSession(data.token, username, data.did || '');
closeModal();
callbacks?.onSuccess?.();
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = '🔐 Create Passkey';
if (err.name === 'NotAllowedError') {
errorEl.textContent = 'Passkey creation was cancelled.';
} else {
errorEl.textContent = err.message || 'Registration failed.';
}
}
}
function attachModalListeners() {
overlay.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
closeModal();
callbacks?.onCancel?.();
});
overlay.querySelector('[data-action="signin"]')?.addEventListener('click', handleSignIn);
overlay.querySelector('[data-action="register"]')?.addEventListener('click', handleRegister);
overlay.querySelector('[data-action="switch-register"]')?.addEventListener('click', () => {
mode = 'register';
render();
// Focus the username input
setTimeout(() => (overlay.querySelector('#auth-username') as HTMLInputElement)?.focus(), 50);
});
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener('click', () => {
mode = 'signin';
render();
});
// Handle Enter key in username input
overlay.querySelector('#auth-username')?.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') handleRegister();
});
// Close on overlay background click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeModal();
callbacks?.onCancel?.();
}
});
}
function closeModal() {
activeModal?.remove();
activeModal = null;
}
activeModal = overlay;
document.body.appendChild(overlay);
render();
}
// ============================================================================
// HEADER COMPONENT
// ============================================================================
export interface HeaderOptions {
/** 'dark' for dark pages (landing), 'light' for canvas */
theme: 'dark' | 'light';
/** Show the rSpace brand link on the left */
showBrand?: boolean;
/** Called after successful sign-in/out to refresh page state */
onAuthChange?: () => void;
}
/**
* Mount the rSpace header bar at the top of the page.
* Handles sign-in/sign-up with EncryptID and shows user state.
*/
export function mountHeader(options: HeaderOptions): void {
const { theme, showBrand = true, onAuthChange } = options;
// Inject styles
if (!document.getElementById('rspace-header-styles')) {
const style = document.createElement('style');
style.id = 'rspace-header-styles';
style.textContent = HEADER_STYLES;
document.head.appendChild(style);
}
// Create header element
const header = document.createElement('header');
header.className = `rspace-header rspace-header--${theme}`;
header.id = 'rspace-header';
function renderHeader() {
const session = getSession();
const isLoggedIn = session !== null;
const brandHTML = showBrand
? `<a href="/" class="rspace-header__brand">
<span class="rspace-header__brand-gradient">rSpace</span>
</a>`
: '<div></div>';
if (isLoggedIn) {
const username = session!.claims.username || '';
const did = session!.claims.did || session!.claims.sub;
const displayName = username || (did.length > 24 ? did.slice(0, 16) + '...' + did.slice(-6) : did);
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
header.innerHTML = `
${brandHTML}
<div class="rspace-header__right">
<div class="rspace-header__user" id="header-user-toggle">
<div class="rspace-header__avatar">${initial}</div>
<span class="rspace-header__user-did">${displayName}</span>
<div class="rspace-header__dropdown" id="header-dropdown">
<button class="rspace-header__dropdown-item" data-action="profile">
👤 Profile
</button>
<button class="rspace-header__dropdown-item" data-action="recovery">
🛡️ Recovery Settings
</button>
<div class="rspace-header__dropdown-divider"></div>
<button class="rspace-header__dropdown-item rspace-header__dropdown-item--danger" data-action="signout">
🚪 Sign Out
</button>
</div>
</div>
</div>
`;
} else {
header.innerHTML = `
${brandHTML}
<div class="rspace-header__right">
<button class="rspace-header__signin-btn" id="header-signin-btn">
🔑 Sign In
</button>
</div>
`;
}
attachHeaderListeners();
}
function attachHeaderListeners() {
// Sign in button
document.getElementById('header-signin-btn')?.addEventListener('click', () => {
showAuthModal({
onSuccess: () => {
renderHeader();
onAuthChange?.();
},
});
});
// User toggle dropdown
const userToggle = document.getElementById('header-user-toggle');
const dropdown = document.getElementById('header-dropdown');
if (userToggle && dropdown) {
userToggle.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
});
document.addEventListener('click', () => {
dropdown.classList.remove('open');
});
}
// Dropdown actions
header.querySelectorAll('[data-action]').forEach((el) => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const action = (el as HTMLElement).dataset.action;
switch (action) {
case 'signout':
clearSession();
renderHeader();
onAuthChange?.();
break;
case 'profile':
window.open(ENCRYPTID_URL, '_blank');
break;
case 'recovery':
window.open(`${ENCRYPTID_URL}/recover`, '_blank');
break;
}
document.getElementById('header-dropdown')?.classList.remove('open');
});
});
}
// Store render function so requireAuth can update the header after auth
headerRenderFn = renderHeader;
// Mount to DOM
document.body.prepend(header);
renderHeader();
}
/**
* Require authentication before proceeding.
* Returns true if user is authenticated, or shows auth modal and returns false.
* Use the callback to continue after successful auth.
*/
export function requireAuth(onAuthenticated: () => void): boolean {
if (isAuthenticated()) {
return true;
}
showAuthModal({
onSuccess: () => {
headerRenderFn?.();
onAuthenticated();
},
});
return false;
}