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:
parent
3f37d92aa0
commit
88aebcd997
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue