feat: add EncryptID passkey authentication to space editor
Requires authentication via EncryptID before accessing the flow editor. Adds AuthButton component, auth store, and middleware. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d5d5be3fa6
commit
a6190636a8
|
|
@ -3,6 +3,8 @@
|
|||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useAuthStore } from '@/lib/auth'
|
||||
import { AuthButton } from '@/components/AuthButton'
|
||||
import { starterNodes } from '@/lib/presets'
|
||||
import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state'
|
||||
import type { FlowNode, SpaceConfig, IntegrationConfig } from '@/lib/types'
|
||||
|
|
@ -30,6 +32,7 @@ const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
|||
})
|
||||
|
||||
export default function SpacePage() {
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
const [currentNodes, setCurrentNodes] = useState<FlowNode[]>(starterNodes)
|
||||
const [integrations, setIntegrations] = useState<IntegrationConfig | undefined>()
|
||||
const [spaceName, setSpaceName] = useState('')
|
||||
|
|
@ -238,6 +241,15 @@ export default function SpacePage() {
|
|||
showStatus(`Loaded flow "${flow.name}" (${flow.status})`, 'success')
|
||||
}, [showStatus])
|
||||
|
||||
// Set auth cookie for middleware (so server-side can check)
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
document.cookie = `encryptid_token=${token}; path=/; max-age=86400; SameSite=Lax`
|
||||
} else {
|
||||
document.cookie = 'encryptid_token=; path=/; max-age=0'
|
||||
}
|
||||
}, [token])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-slate-50">
|
||||
|
|
@ -249,6 +261,21 @@ export default function SpacePage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col items-center justify-center bg-slate-50 gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Sign in to access your Space</h1>
|
||||
<p className="text-slate-500">EncryptID passkey authentication is required to create and edit funding flows.</p>
|
||||
</div>
|
||||
<AuthButton />
|
||||
<Link href="/" className="text-sm text-slate-400 hover:text-blue-500 underline">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="h-screen w-screen flex flex-col">
|
||||
{/* Status Toast */}
|
||||
|
|
@ -271,6 +298,8 @@ export default function SpacePage() {
|
|||
</Link>
|
||||
<span className="text-slate-500">|</span>
|
||||
<span className="text-slate-300">Your Space</span>
|
||||
<span className="text-slate-500">|</span>
|
||||
<AuthButton />
|
||||
{deployedFlow && (
|
||||
<>
|
||||
<span className="text-slate-500">|</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/lib/auth'
|
||||
|
||||
export function AuthButton() {
|
||||
const { isAuthenticated, username, loading, login, register, logout } = useAuthStore()
|
||||
const [showRegister, setShowRegister] = useState(false)
|
||||
const [registerName, setRegisterName] = useState('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await login()
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name === 'NotAllowedError') {
|
||||
setShowRegister(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!registerName.trim()) return
|
||||
try {
|
||||
await register(registerName.trim())
|
||||
setShowRegister(false)
|
||||
setRegisterName('')
|
||||
} catch (e: unknown) {
|
||||
alert('Registration failed: ' + (e instanceof Error ? e.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <span className="text-sm text-slate-400">Authenticating...</span>
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">
|
||||
Signed in as <strong className="text-blue-600">{username}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-slate-400 hover:text-slate-600 underline text-xs"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showRegister) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={registerName}
|
||||
onChange={(e) => setRegisterName(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRegister(false)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:border-blue-400 hover:text-blue-600 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="10" r="3" /><path d="M12 13v8" /><path d="M9 18h6" /><circle cx="12" cy="10" r="7" />
|
||||
</svg>
|
||||
Sign in with Passkey
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* EncryptID Auth Store for rfunds-online
|
||||
*
|
||||
* Provides passkey-based identity for space creation and editing.
|
||||
* Uses Zustand with localStorage persistence.
|
||||
*/
|
||||
|
||||
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 {
|
||||
isAuthenticated: boolean
|
||||
token: string | null
|
||||
did: string | null
|
||||
username: string | null
|
||||
loading: boolean
|
||||
|
||||
login: () => Promise<void>
|
||||
register: (username: string) => Promise<void>
|
||||
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 {
|
||||
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { options } = await startRes.json()
|
||||
|
||||
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: fromBase64url(options.challenge).buffer as ArrayBuffer,
|
||||
rpId: options.rpId,
|
||||
userVerification: options.userVerification as UserVerificationRequirement,
|
||||
timeout: options.timeout,
|
||||
allowCredentials: options.allowCredentials?.map((c: { type: string; id: string; transports: string[] }) => ({
|
||||
type: c.type as PublicKeyCredentialType,
|
||||
id: fromBase64url(c.id).buffer as ArrayBuffer,
|
||||
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
|
||||
|
||||
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 {
|
||||
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()
|
||||
|
||||
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge: fromBase64url(options.challenge).buffer as ArrayBuffer,
|
||||
rp: options.rp,
|
||||
user: {
|
||||
id: fromBase64url(options.user.id).buffer as ArrayBuffer,
|
||||
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
|
||||
|
||||
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 AuthenticatorAttestationResponse & { getTransports?: () => string[] }).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: 'rfunds-auth',
|
||||
partialize: (state) => ({
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
token: state.token,
|
||||
did: state.did,
|
||||
username: state.username,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
/**
|
||||
* Middleware to protect /space routes.
|
||||
*
|
||||
* Client-side auth enforcement: the space page itself checks auth state via
|
||||
* Zustand store. This middleware adds a cookie-based check for server-rendered
|
||||
* requests — if no encryptid_token cookie is present on /space, redirect to
|
||||
* the home page with a login hint.
|
||||
*
|
||||
* Note: Since rfunds uses client-side Zustand persistence (localStorage),
|
||||
* the primary auth gate is in the SpacePage component itself. This middleware
|
||||
* serves as an additional layer for direct URL access.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Only protect /space routes (not /tbff which is a public demo)
|
||||
if (pathname.startsWith('/space')) {
|
||||
// Check for auth token in cookie (set by client after login)
|
||||
const token = request.cookies.get('encryptid_token')?.value
|
||||
|
||||
// Also check Authorization header for API-style access
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token && !bearerToken) {
|
||||
// No auth — redirect to home with login hint
|
||||
// The client-side auth store is the primary gate, but this catches
|
||||
// direct navigation before hydration
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/'
|
||||
url.searchParams.set('login', 'required')
|
||||
url.searchParams.set('return', pathname)
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/space/:path*'],
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"next": "14.2.35",
|
||||
|
|
|
|||
Loading…
Reference in New Issue