feat: add optional EncryptID passkey authentication

Add optional passkey identity (anonymous access remains default):
- Add Zustand auth store with EncryptID login/register/logout
- Add AuthButton component to home page
- Auto-fill name from EncryptID when authenticated
- Use DID as persistent participant ID in rooms when signed in
- Update useRoom hook to accept optional encryptIdDid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:34:28 -07:00
parent 3f37d92aa0
commit 88aebcd997
5 changed files with 323 additions and 4 deletions

View File

@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useRoom } from '@/hooks/useRoom'; import { useRoom } from '@/hooks/useRoom';
import { useAuthStore } from '@/stores/auth';
import { useLocationSharing } from '@/hooks/useLocationSharing'; import { useLocationSharing } from '@/hooks/useLocationSharing';
import { useServiceWorkerMessages } from '@/hooks/useServiceWorkerMessages'; import { useServiceWorkerMessages } from '@/hooks/useServiceWorkerMessages';
import { usePushNotifications } from '@/hooks/usePushNotifications'; import { usePushNotifications } from '@/hooks/usePushNotifications';
@ -32,6 +33,7 @@ export default function RoomPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const slug = params.slug as string; const slug = params.slug as string;
const { did: encryptIdDid } = useAuthStore();
const [showShare, setShowShare] = useState(false); const [showShare, setShowShare] = useState(false);
const [showParticipants, setShowParticipants] = useState(true); const [showParticipants, setShowParticipants] = useState(true);
@ -92,6 +94,7 @@ export default function RoomPage() {
slug, slug,
userName: currentUser?.name || '', userName: currentUser?.name || '',
userEmoji: currentUser?.emoji || '👤', userEmoji: currentUser?.emoji || '👤',
encryptIdDid,
}); });
// Use refs to avoid stale closures in callbacks // Use refs to avoid stale closures in callbacks

View File

@ -3,6 +3,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { AuthButton } from '@/components/AuthButton';
import { useAuthStore } from '@/stores/auth';
// Emoji options for avatars // Emoji options for avatars
const EMOJI_OPTIONS = ['🐙', '🦊', '🐻', '🐱', '🦝', '🐸', '🦉', '🐧', '🦋', '🐝']; const EMOJI_OPTIONS = ['🐙', '🦊', '🐻', '🐱', '🦝', '🐸', '🦉', '🐧', '🦋', '🐝'];
@ -14,6 +16,7 @@ function generateSlug(): string {
export default function HomePage() { export default function HomePage() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, username: authUsername } = useAuthStore();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [joinSlug, setJoinSlug] = useState(''); const [joinSlug, setJoinSlug] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
@ -50,6 +53,13 @@ export default function HomePage() {
setIsLoaded(true); setIsLoaded(true);
}, []); }, []);
// Auto-fill name from EncryptID when authenticated
useEffect(() => {
if (isAuthenticated && authUsername && !name) {
setName(authUsername);
}
}, [isAuthenticated, authUsername, name]);
const handleCreateRoom = async () => { const handleCreateRoom = async () => {
if (!name.trim()) return; if (!name.trim()) return;
@ -91,6 +101,9 @@ export default function HomePage() {
<span className="text-rmaps-primary">r</span>Maps <span className="text-rmaps-primary">r</span>Maps
</h1> </h1>
<p className="text-white/60">Find your friends at events</p> <p className="text-white/60">Find your friends at events</p>
<div className="mt-3">
<AuthButton />
</div>
</div> </div>
{/* Quick Rejoin Card - show when user has saved info and last room */} {/* Quick Rejoin Card - show when user has saved info and last room */}

View File

@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { useAuthStore } from '@/stores/auth';
export function AuthButton() {
const { isAuthenticated, username, did, loading, login, register, logout } = useAuthStore();
const [showRegister, setShowRegister] = useState(false);
const [regUsername, setRegUsername] = useState('');
const [error, setError] = useState('');
if (isAuthenticated) {
return (
<div className="flex items-center gap-3">
<div className="text-sm">
<span className="text-white/60">Signed in as </span>
<span className="text-rmaps-primary font-medium">{username || did?.slice(0, 16) + '...'}</span>
</div>
<button
onClick={logout}
className="text-xs text-white/40 hover:text-white/60 transition-colors"
>
Sign out
</button>
</div>
);
}
if (showRegister) {
return (
<div className="flex items-center gap-2">
<input
type="text"
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
placeholder="Choose a username"
className="input text-sm py-1 px-2 w-40"
maxLength={20}
/>
<button
onClick={async () => {
if (!regUsername.trim()) return;
setError('');
try {
await register(regUsername.trim());
} catch (e: any) {
setError(e.message || 'Registration failed');
}
}}
disabled={loading || !regUsername.trim()}
className="btn-primary text-sm py-1 px-3"
>
{loading ? '...' : 'Register'}
</button>
<button
onClick={() => setShowRegister(false)}
className="text-xs text-white/40 hover:text-white/60"
>
Cancel
</button>
{error && <span className="text-xs text-red-400">{error}</span>}
</div>
);
}
return (
<div className="flex items-center gap-2">
<button
onClick={async () => {
setError('');
try {
await login();
} catch (e: any) {
if (e.name === 'NotAllowedError') {
setShowRegister(true);
} else {
setError(e.message || 'Sign in failed');
}
}
}}
disabled={loading}
className="text-sm text-white/60 hover:text-rmaps-primary transition-colors flex items-center gap-1"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-4 h-4">
<circle cx={12} cy={10} r={3} />
<path d="M12 13v8" />
<path d="M9 18h6" />
<circle cx={12} cy={10} r={7} />
</svg>
{loading ? 'Signing in...' : 'Sign in with Passkey'}
</button>
{error && <span className="text-xs text-red-400">{error}</span>}
</div>
);
}

View File

@ -62,9 +62,16 @@ async function loadRoomStateFromSW(slug: string): Promise<{ participants: Partic
/** /**
* Get or create a persistent participant ID for this browser/room combination. * Get or create a persistent participant ID for this browser/room combination.
* This prevents creating duplicate "ghost" participants on page reload. * Uses EncryptID DID when authenticated for cross-session persistence,
* otherwise falls back to localStorage nanoid.
*/ */
function getOrCreateParticipantId(slug: string): string { function getOrCreateParticipantId(slug: string, encryptIdDid?: string | null): string {
// If authenticated with EncryptID, use DID as stable identity
if (encryptIdDid) {
console.log('Using EncryptID DID as participant ID:', encryptIdDid.slice(0, 20) + '...');
return encryptIdDid;
}
const storageKey = `rmaps_participant_id_${slug}`; const storageKey = `rmaps_participant_id_${slug}`;
try { try {
@ -90,6 +97,8 @@ interface UseRoomOptions {
slug: string; slug: string;
userName: string; userName: string;
userEmoji: string; userEmoji: string;
/** EncryptID DID for persistent cross-session identity (optional) */
encryptIdDid?: string | null;
} }
interface UseRoomReturn { interface UseRoomReturn {
@ -110,7 +119,7 @@ interface UseRoomReturn {
setLocationRequestCallback: (callback: () => void) => void; setLocationRequestCallback: (callback: () => void) => void;
} }
export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomReturn { export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOptions): UseRoomReturn {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -169,7 +178,7 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
setError(null); setError(null);
// Use persistent participant ID to avoid creating duplicate "ghost" participants // Use persistent participant ID to avoid creating duplicate "ghost" participants
const participantId = getOrCreateParticipantId(slug); const participantId = getOrCreateParticipantId(slug, encryptIdDid);
participantIdRef.current = participantId; participantIdRef.current = participantId;
const color = COLORS[Math.floor(Math.random() * COLORS.length)]; const color = COLORS[Math.floor(Math.random() * COLORS.length)];

199
src/stores/auth.ts Normal file
View File

@ -0,0 +1,199 @@
/**
* EncryptID Auth Store for rmaps-online
*
* Optional authentication anonymous access remains the default.
* When authenticated, the user gets a persistent DID-based identity.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
interface AuthState {
/** Whether the user is authenticated via EncryptID */
isAuthenticated: boolean;
/** EncryptID JWT token */
token: string | null;
/** User's DID (persistent identity) */
did: string | null;
/** Username from EncryptID */
username: string | null;
/** Whether auth is loading */
loading: boolean;
/** Authenticate with EncryptID passkey */
login: () => Promise<void>;
/** Register a new EncryptID passkey */
register: (username: string) => Promise<void>;
/** Clear auth state */
logout: () => void;
}
function toBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function fromBase64url(str: string): Uint8Array {
return Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
isAuthenticated: false,
token: null,
did: null,
username: null,
loading: false,
login: async () => {
set({ loading: true });
try {
// Step 1: Get auth options
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
// Step 2: WebAuthn ceremony
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
challenge: fromBase64url(options.challenge),
rpId: options.rpId,
userVerification: options.userVerification as UserVerificationRequirement,
timeout: options.timeout,
allowCredentials: options.allowCredentials?.map((c: any) => ({
type: c.type as PublicKeyCredentialType,
id: fromBase64url(c.id),
transports: c.transports as AuthenticatorTransport[],
})),
};
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential;
if (!assertion) throw new Error('Authentication cancelled');
const response = assertion.response as AuthenticatorAssertionResponse;
// Step 3: Complete auth
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId: assertion.id,
authenticatorData: toBase64url(response.authenticatorData),
clientDataJSON: toBase64url(response.clientDataJSON),
signature: toBase64url(response.signature),
userHandle: response.userHandle ? toBase64url(response.userHandle) : null,
},
}),
});
const result = await completeRes.json();
if (!result.success) throw new Error(result.error || 'Authentication failed');
set({
isAuthenticated: true,
token: result.token,
did: result.did,
username: result.username,
loading: false,
});
} catch (error) {
set({ loading: false });
throw error;
}
},
register: async (username: string) => {
set({ loading: true });
try {
// Step 1: Get registration options
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/register/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
const { options, userId } = await startRes.json();
// Step 2: WebAuthn ceremony
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
challenge: fromBase64url(options.challenge),
rp: options.rp,
user: {
id: fromBase64url(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams,
authenticatorSelection: options.authenticatorSelection,
timeout: options.timeout,
attestation: options.attestation as AttestationConveyancePreference,
};
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential;
if (!credential) throw new Error('Registration cancelled');
const response = credential.response as AuthenticatorAttestationResponse;
// Step 3: Complete registration
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/register/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
userId,
username,
credential: {
credentialId: credential.id,
publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject),
attestationObject: toBase64url(response.attestationObject),
clientDataJSON: toBase64url(response.clientDataJSON),
transports: (response as any).getTransports?.() || [],
},
}),
});
const result = await completeRes.json();
if (!result.success) throw new Error(result.error || 'Registration failed');
set({
isAuthenticated: true,
token: result.token,
did: result.did,
username,
loading: false,
});
} catch (error) {
set({ loading: false });
throw error;
}
},
logout: () => {
set({
isAuthenticated: false,
token: null,
did: null,
username: null,
loading: false,
});
},
}),
{
name: 'rmaps-auth',
partialize: (state) => ({
isAuthenticated: state.isAuthenticated,
token: state.token,
did: state.did,
username: state.username,
}),
}
)
);