diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx
index 2196a70..48efe6c 100644
--- a/src/app/[slug]/page.tsx
+++ b/src/app/[slug]/page.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useRoom } from '@/hooks/useRoom';
+import { useAuthStore } from '@/stores/auth';
import { useLocationSharing } from '@/hooks/useLocationSharing';
import { useServiceWorkerMessages } from '@/hooks/useServiceWorkerMessages';
import { usePushNotifications } from '@/hooks/usePushNotifications';
@@ -32,6 +33,7 @@ export default function RoomPage() {
const params = useParams();
const router = useRouter();
const slug = params.slug as string;
+ const { did: encryptIdDid } = useAuthStore();
const [showShare, setShowShare] = useState(false);
const [showParticipants, setShowParticipants] = useState(true);
@@ -92,6 +94,7 @@ export default function RoomPage() {
slug,
userName: currentUser?.name || '',
userEmoji: currentUser?.emoji || '👤',
+ encryptIdDid,
});
// Use refs to avoid stale closures in callbacks
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 37ddc3a..08ed137 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -3,6 +3,8 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { nanoid } from 'nanoid';
+import { AuthButton } from '@/components/AuthButton';
+import { useAuthStore } from '@/stores/auth';
// Emoji options for avatars
const EMOJI_OPTIONS = ['🐙', '🦊', '🐻', '🐱', '🦝', '🐸', '🦉', '🐧', '🦋', '🐝'];
@@ -14,6 +16,7 @@ function generateSlug(): string {
export default function HomePage() {
const router = useRouter();
+ const { isAuthenticated, username: authUsername } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [joinSlug, setJoinSlug] = useState('');
const [name, setName] = useState('');
@@ -50,6 +53,13 @@ export default function HomePage() {
setIsLoaded(true);
}, []);
+ // Auto-fill name from EncryptID when authenticated
+ useEffect(() => {
+ if (isAuthenticated && authUsername && !name) {
+ setName(authUsername);
+ }
+ }, [isAuthenticated, authUsername, name]);
+
const handleCreateRoom = async () => {
if (!name.trim()) return;
@@ -91,6 +101,9 @@ export default function HomePage() {
rMaps
Find your friends at events
+
{/* Quick Rejoin Card - show when user has saved info and last room */}
diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx
new file mode 100644
index 0000000..146259f
--- /dev/null
+++ b/src/components/AuthButton.tsx
@@ -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 (
+
+
+ Signed in as
+ {username || did?.slice(0, 16) + '...'}
+
+
+
+ );
+ }
+
+ if (showRegister) {
+ return (
+
+ setRegUsername(e.target.value)}
+ placeholder="Choose a username"
+ className="input text-sm py-1 px-2 w-40"
+ maxLength={20}
+ />
+
+
+ {error && {error}}
+
+ );
+ }
+
+ return (
+
+
+ {error &&
{error}}
+
+ );
+}
diff --git a/src/hooks/useRoom.ts b/src/hooks/useRoom.ts
index a8c5bb0..fd4beb9 100644
--- a/src/hooks/useRoom.ts
+++ b/src/hooks/useRoom.ts
@@ -62,9 +62,16 @@ async function loadRoomStateFromSW(slug: string): Promise<{ participants: Partic
/**
* 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}`;
try {
@@ -90,6 +97,8 @@ interface UseRoomOptions {
slug: string;
userName: string;
userEmoji: string;
+ /** EncryptID DID for persistent cross-session identity (optional) */
+ encryptIdDid?: string | null;
}
interface UseRoomReturn {
@@ -110,7 +119,7 @@ interface UseRoomReturn {
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 [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -169,7 +178,7 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
setError(null);
// Use persistent participant ID to avoid creating duplicate "ghost" participants
- const participantId = getOrCreateParticipantId(slug);
+ const participantId = getOrCreateParticipantId(slug, encryptIdDid);
participantIdRef.current = participantId;
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
diff --git a/src/stores/auth.ts b/src/stores/auth.ts
new file mode 100644
index 0000000..1430139
--- /dev/null
+++ b/src/stores/auth.ts
@@ -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;
+ /** Register a new EncryptID passkey */
+ register: (username: string) => Promise;
+ /** 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()(
+ 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,
+ }),
+ }
+ )
+);