mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
@@ -0,0 +1,16 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_customize"
|
||||
tag_format = "web-auth/v$version"
|
||||
ignored_tag_formats = ["*/v${version}", "v${version}"]
|
||||
version_scheme = "semver"
|
||||
version_provider = "npm"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
pre_bump_hooks = ["bash ../../scripts/hook-bump-pre.sh"]
|
||||
post_bump_hooks = ["pnpm --filter '@sonr.io/auth' deploy:cloudflare"]
|
||||
|
||||
[tool.commitizen.customize]
|
||||
bump_pattern = "^(feat|fix|refactor|perf|BREAKING CHANGE)"
|
||||
bump_map = { "BREAKING CHANGE" = "MAJOR", "feat" = "MINOR", "fix" = "PATCH", "refactor" = "PATCH", "perf" = "PATCH" }
|
||||
default_bump = "PATCH"
|
||||
changelog_pattern = "^(feat|fix|refactor|docs|build)\\(auth\\)(!)?:"
|
||||
@@ -0,0 +1,135 @@
|
||||
# Sonr Auth - Environment Configuration
|
||||
|
||||
# ==============================================================================
|
||||
# Bridge URL Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Bridge service URL (backend Go service)
|
||||
# Production: https://api.sonr.id
|
||||
# Development: http://localhost:8080
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
|
||||
# ==============================================================================
|
||||
# OpenID Connect Provider Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# OIDC Issuer URL (must be publicly accessible)
|
||||
# This URL will be used in discovery documents and token validation
|
||||
# Production: https://auth.sonr.id
|
||||
# Development: http://localhost:3000
|
||||
OIDC_ISSUER=http://localhost:3000
|
||||
|
||||
# Public URL for OIDC provider (if different from issuer)
|
||||
# Used for external references and redirects
|
||||
OIDC_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Private key path for JWT token signing (PEM format)
|
||||
# Generate with: openssl ecparam -genkey -name prime256v1 -noout -out oidc-signing.pem
|
||||
OIDC_SIGNING_KEY_PATH=./keys/oidc-signing.pem
|
||||
|
||||
# Private key path for JWT token encryption (optional)
|
||||
# Generate with: openssl ecparam -genkey -name prime256v1 -noout -out oidc-encryption.pem
|
||||
OIDC_ENCRYPTION_KEY_PATH=./keys/oidc-encryption.pem
|
||||
|
||||
# ==============================================================================
|
||||
# WebAuthn Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Relying Party ID (usually the domain)
|
||||
# Must match the domain where WebAuthn is used
|
||||
# Production: sonr.id
|
||||
# Development: localhost
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
|
||||
# Relying Party Name (displayed to users)
|
||||
WEBAUTHN_RP_NAME=Sonr Identity Platform
|
||||
|
||||
# WebAuthn timeout in milliseconds
|
||||
WEBAUTHN_TIMEOUT=60000
|
||||
|
||||
# ==============================================================================
|
||||
# JWT Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Secret key for JWT token generation and validation
|
||||
# Use a strong, random string in production
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=highway-ucan-secret-key
|
||||
|
||||
# ==============================================================================
|
||||
# Redis Configuration (for session storage)
|
||||
# ==============================================================================
|
||||
|
||||
# Redis connection string
|
||||
# Production: Use Redis cluster or managed Redis service
|
||||
# Development: Local Redis instance
|
||||
REDIS_ADDR=127.0.0.1:6379
|
||||
|
||||
# Redis password (if required)
|
||||
# REDIS_PASSWORD=your-redis-password
|
||||
|
||||
# Redis database number
|
||||
# REDIS_DB=0
|
||||
|
||||
# ==============================================================================
|
||||
# OIDC Client Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Default client ID for self-issued tokens
|
||||
OIDC_CLIENT_ID=sonr-auth
|
||||
|
||||
# Allowed redirect URIs (comma-separated)
|
||||
# Add all domains that will use this OIDC provider
|
||||
OIDC_ALLOWED_REDIRECT_URIS=http://localhost:3000/callback,http://localhost:3001/callback,https://localhost:3000/callback,https://localhost:3001/callback
|
||||
|
||||
# Supported scopes (comma-separated)
|
||||
OIDC_SUPPORTED_SCOPES=openid,profile,email,did,vault,offline_access
|
||||
|
||||
# ==============================================================================
|
||||
# Development Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Enable debug logging
|
||||
DEBUG=false
|
||||
|
||||
# Log level (error, warn, info, debug)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Enable CORS for development
|
||||
CORS_ENABLED=true
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://localhost:3000,https://localhost:3001
|
||||
|
||||
# ==============================================================================
|
||||
# Production Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Domain for secure cookies
|
||||
# COOKIE_DOMAIN=.sonr.id
|
||||
|
||||
# Secure cookie settings (true for HTTPS)
|
||||
# COOKIE_SECURE=true
|
||||
|
||||
# SameSite cookie attribute (strict, lax, none)
|
||||
# COOKIE_SAME_SITE=lax
|
||||
|
||||
# Session timeout in milliseconds (default: 1 hour)
|
||||
# SESSION_TIMEOUT=3600000
|
||||
|
||||
# Refresh token TTL in milliseconds (default: 7 days)
|
||||
# REFRESH_TOKEN_TTL=604800000
|
||||
|
||||
# ==============================================================================
|
||||
# Optional Integrations
|
||||
# ==============================================================================
|
||||
|
||||
# IPFS configuration (if using decentralized storage)
|
||||
# IPFS_API_URL=http://localhost:5001
|
||||
|
||||
# Blockchain RPC endpoint (if direct blockchain interaction is needed)
|
||||
# BLOCKCHAIN_RPC_URL=http://localhost:26657
|
||||
|
||||
# Analytics configuration
|
||||
# ANALYTICS_ENABLED=false
|
||||
# ANALYTICS_API_KEY=your-analytics-key
|
||||
@@ -0,0 +1,23 @@
|
||||
# Staging Environment Configuration for Auth App
|
||||
NEXT_PUBLIC_ENVIRONMENT=staging
|
||||
NEXT_PUBLIC_API_URL=https://highway-api-staging.workers.dev
|
||||
NEXT_PUBLIC_APP_URL=https://staging.auth.sonr.id
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://staging.app.sonr.id
|
||||
NEXT_PUBLIC_MAIN_SITE_URL=https://staging.sonr.id
|
||||
|
||||
# WebAuthn Configuration
|
||||
NEXT_PUBLIC_RP_ID=staging.highway.sonr.id
|
||||
NEXT_PUBLIC_RP_NAME="Highway Staging"
|
||||
|
||||
# Feature Flags
|
||||
NEXT_PUBLIC_ENABLE_DEBUG=true
|
||||
NEXT_PUBLIC_ENABLE_DEV_TOOLS=true
|
||||
|
||||
# Analytics (if applicable)
|
||||
NEXT_PUBLIC_ANALYTICS_ID=staging-analytics-id
|
||||
|
||||
# Error Tracking
|
||||
NEXT_PUBLIC_SENTRY_DSN=staging-sentry-dsn
|
||||
|
||||
# Logging
|
||||
NEXT_PUBLIC_LOG_LEVEL=info
|
||||
@@ -0,0 +1,71 @@
|
||||
# Use Node.js Alpine as base image
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy root package files and workspace configuration
|
||||
COPY ../../package.json ../../pnpm-lock.yaml ../../pnpm-workspace.yaml ./
|
||||
COPY ../../turbo.json ./
|
||||
|
||||
# Copy package.json files for all workspace dependencies
|
||||
COPY ../../packages/es/package.json ./packages/es/
|
||||
COPY ../../packages/sdk/package.json ./packages/sdk/
|
||||
COPY ../../packages/ui/package.json ./packages/ui/
|
||||
COPY ../../packages/com/package.json ./packages/com/
|
||||
COPY ../../packages/pkl/package.json ./packages/pkl/
|
||||
COPY ../../packages/cli/package.json ./packages/cli/
|
||||
COPY ./package.json ./web/auth/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy installed dependencies
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/.pnpm ./.pnpm
|
||||
|
||||
# Copy source code
|
||||
COPY ../.. .
|
||||
|
||||
# Build the application
|
||||
WORKDIR /app/web/auth
|
||||
RUN pnpm build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/web/auth/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/auth/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/auth/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
ENV PORT=3001
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,158 @@
|
||||
# Auth Frontend
|
||||
|
||||
This is the Next.js authentication frontend for Highway WebAuthn gateway. It provides a modern, responsive interface for passwordless authentication using WebAuthn passkeys.
|
||||
|
||||
## Features
|
||||
|
||||
- **Passwordless Authentication**: WebAuthn registration and login with passkeys
|
||||
- **Modern UI**: Clean, responsive design with Tailwind CSS
|
||||
- **Session Management**: Persistent sessions with automatic validation
|
||||
- **Browser Compatibility**: WebAuthn feature detection and fallback messaging
|
||||
- **Accessibility**: ARIA labels, keyboard navigation, and screen reader support
|
||||
- **TypeScript**: Full type safety with custom hooks and components
|
||||
|
||||
## Pages
|
||||
|
||||
### Home (`/`)
|
||||
|
||||
- Landing page with feature highlights
|
||||
- Navigation to registration and login
|
||||
- Automatic redirect to dashboard if authenticated
|
||||
|
||||
### Registration (`/register`)
|
||||
|
||||
- WebAuthn passkey registration flow
|
||||
- Username and display name input
|
||||
- Real-time validation and error handling
|
||||
|
||||
### Login (`/login`)
|
||||
|
||||
- WebAuthn passkey authentication
|
||||
- Username input with passkey verification
|
||||
- Remember me functionality
|
||||
|
||||
### Dashboard (`/dashboard`)
|
||||
|
||||
- User profile information
|
||||
- Account security features
|
||||
- Session management controls
|
||||
|
||||
## Components
|
||||
|
||||
### Custom Hooks
|
||||
|
||||
- `useWebAuthn`: WebAuthn registration and authentication logic
|
||||
- `useSession`: Session management and user state
|
||||
|
||||
### UI Components (from `@sonr.io/ui`)
|
||||
|
||||
- `Button`: Configurable button with loading states
|
||||
- `Input`: Form input with validation and help text
|
||||
- `ErrorAlert`: Dismissible error notifications
|
||||
|
||||
## Development
|
||||
|
||||
To start the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This will start the Next.js development server with hot reload at `http://localhost:3000`.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Test WebAuthn in browser
|
||||
# Visit http://localhost:3000 and test registration/login flow
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy to Cloudflare Pages:
|
||||
|
||||
```bash
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
The app is configured for static export and optimized for Cloudflare Pages deployment.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# API endpoint
|
||||
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
|
||||
|
||||
# Development
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
### Next.js Configuration
|
||||
|
||||
The app uses:
|
||||
|
||||
- Static export for Cloudflare Pages
|
||||
- Unoptimized images for static hosting
|
||||
- Trailing slash for proper routing
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### ✅ **Phase 3: WebAuthn Frontend Implementation** (Current)
|
||||
|
||||
- [x] **Next.js 14 Setup**: App directory with TypeScript configuration
|
||||
- [x] **WebAuthn Integration**: Complete registration and authentication flows
|
||||
- [x] **Custom Hooks**: useWebAuthn and useSession for state management
|
||||
- [x] **UI Components**: Reusable Button, Input, and ErrorAlert components
|
||||
- [x] **Authentication Pages**: Registration, login, and dashboard interfaces
|
||||
- [x] **Session Management**: Persistent sessions with browser compatibility
|
||||
- [x] **Responsive Design**: Mobile-first approach with Tailwind CSS
|
||||
- [x] **Accessibility**: ARIA labels, keyboard navigation, and screen reader support
|
||||
|
||||
### 🚧 **Phase 5: Production Readiness** (Next)
|
||||
|
||||
- [ ] **Performance Optimization**: Code splitting and bundle optimization
|
||||
- [ ] **Testing Suite**: Unit tests for components and hooks
|
||||
- [ ] **E2E Testing**: Cypress or Playwright for full user flow testing
|
||||
- [ ] **Error Boundaries**: React error boundaries for graceful error handling
|
||||
- [ ] **Analytics**: User behavior tracking and conversion metrics
|
||||
- [ ] **Internationalization**: Multi-language support
|
||||
|
||||
### 🔮 **Future Enhancements**
|
||||
|
||||
- [ ] **Advanced UI**: Dark mode, animations, and enhanced UX
|
||||
- [ ] **Progressive Web App**: PWA features for mobile experience
|
||||
- [ ] **Multi-factor Authentication**: Additional security options
|
||||
- [ ] **Account Management**: Profile editing and security settings
|
||||
- [ ] **Admin Dashboard**: User management and system monitoring
|
||||
- [ ] **Mobile App**: React Native or native mobile applications
|
||||
|
||||
## Architecture
|
||||
|
||||
The frontend follows modern React patterns:
|
||||
|
||||
- **App Router**: Next.js 13+ app directory structure
|
||||
- **Custom Hooks**: Reusable logic for WebAuthn and session management
|
||||
- **Component Library**: Shared UI components with TypeScript
|
||||
- **State Management**: React hooks for local state, no external state library
|
||||
- **Styling**: Tailwind CSS with custom component styling
|
||||
|
||||
## Security
|
||||
|
||||
- **WebAuthn**: Cryptographic authentication without passwords
|
||||
- **Session Validation**: Automatic session validation and renewal
|
||||
- **Input Validation**: Client-side validation with server-side verification
|
||||
- **HTTPS Only**: Secure connection required for WebAuthn
|
||||
- **Content Security Policy**: CSP headers for XSS protection
|
||||
|
||||
## Browser Support
|
||||
|
||||
- **Chrome**: Full WebAuthn support
|
||||
- **Firefox**: Full WebAuthn support
|
||||
- **Safari**: Full WebAuthn support (iOS 14+)
|
||||
- **Edge**: Full WebAuthn support
|
||||
- **Mobile**: Android and iOS with platform authenticators
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||
"files": {
|
||||
"includes": ["src/**/*.{js,jsx,ts,tsx}"],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noImplicitAnyLet": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"useOptionalChain": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn",
|
||||
"noParameterAssign": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@sonr.io/ui/lib/utils",
|
||||
"ui": "@sonr.io/ui/components"
|
||||
},
|
||||
"iconLibrary": "lucide-react"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/pages/_app.tsx',
|
||||
'!src/pages/_document.tsx',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
transform: {
|
||||
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
|
||||
},
|
||||
transformIgnorePatterns: ['/node_modules/', '^.+\\.module\\.(css|sass|scss)$'],
|
||||
};
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,69 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Environment variables
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
NEXT_PUBLIC_WEBAUTHN_RP_ID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || 'localhost',
|
||||
NEXT_PUBLIC_WEBAUTHN_RP_NAME: process.env.NEXT_PUBLIC_WEBAUTHN_RP_NAME || 'Sonr Auth',
|
||||
NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID || 'sonrtest_1-1',
|
||||
},
|
||||
|
||||
// OpenNext handles the output configuration
|
||||
// output is managed by @opennextjs/cloudflare
|
||||
|
||||
// Enable trailing slashes for better static hosting
|
||||
trailingSlash: true,
|
||||
|
||||
// Image optimization settings for static export
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
// Webpack configuration for edge runtime compatibility
|
||||
webpack: (config, { isServer, webpack }) => {
|
||||
// Handle node: protocol imports
|
||||
config.plugins.push(
|
||||
new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => {
|
||||
resource.request = resource.request.replace(/^node:/, '');
|
||||
})
|
||||
);
|
||||
|
||||
// Add fallbacks for node modules in browser/edge
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
stream: false,
|
||||
crypto: false,
|
||||
buffer: false,
|
||||
util: false,
|
||||
process: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Ignore node-specific modules
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'node:fs': false,
|
||||
'node:path': false,
|
||||
'node:stream': false,
|
||||
'node:buffer': false,
|
||||
'node:util': false,
|
||||
'node:process': false,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
// TypeScript and ESLint configuration
|
||||
typescript: {
|
||||
ignoreBuildErrors: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
eslint: {
|
||||
ignoreDuringBuilds: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
export default defineCloudflareConfig();
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@sonr.io/auth",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"build": "next build",
|
||||
"build:cloudflare": "wrangler build",
|
||||
"start": "next start",
|
||||
"preview": "wrangler preview",
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format . --write",
|
||||
"check": "biome check . --write",
|
||||
"deploy:cloudflare": "wrangler deploy",
|
||||
"release": "cz --no-raise 6,21 bump --yes --increment PATCH"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@simplewebauthn/browser": "^9.0.0",
|
||||
"@sonr.io/es": "workspace:*",
|
||||
"@sonr.io/sdk": "workspace:*",
|
||||
"@sonr.io/ui": "workspace:*",
|
||||
"next": "15.5.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.2",
|
||||
"@browser-echo/next": "^1.0.1",
|
||||
"@cloudflare/next-on-pages": "^1.13.16",
|
||||
"@opennextjs/cloudflare": "^1.8.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vercel": "^47.0.5",
|
||||
"wrangler": "^4.34.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /.well-known/openid-configuration
|
||||
*
|
||||
* OpenID Connect Discovery endpoint that returns the OIDC provider configuration.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/discovery.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/discovery`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
// Forward relevant headers
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('OIDC Discovery error:', response.status, response.statusText);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'discovery_error',
|
||||
error_description: 'Failed to retrieve OIDC discovery configuration',
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryConfig = await response.json();
|
||||
|
||||
// Return the discovery configuration with CORS headers
|
||||
return NextResponse.json(discoveryConfig, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC Discovery proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during discovery configuration retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /.well-known/openid-configuration
|
||||
*
|
||||
* Handle preflight CORS requests for the discovery endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { GET } from '../.well-known/openid-configuration/route';
|
||||
|
||||
describe('OIDC Discovery Endpoint', () => {
|
||||
it('returns a valid OpenID Connect configuration', async () => {
|
||||
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const config = await response.json();
|
||||
|
||||
// Basic structure validation
|
||||
expect(config).toHaveProperty('issuer');
|
||||
expect(config).toHaveProperty('authorization_endpoint');
|
||||
expect(config).toHaveProperty('token_endpoint');
|
||||
expect(config).toHaveProperty('userinfo_endpoint');
|
||||
expect(config).toHaveProperty('jwks_uri');
|
||||
|
||||
// Validate specific SIOP requirements
|
||||
expect(config.subject_syntax_types_supported).toContain('did');
|
||||
expect(config.id_token_types_supported).toContain('subject-signed_id_token');
|
||||
});
|
||||
|
||||
it('responds with correct CORS headers', async () => {
|
||||
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
|
||||
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET');
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { POST } from '../token/route';
|
||||
|
||||
describe('OIDC Token Endpoint', () => {
|
||||
it('handles authorization code token exchange', async () => {
|
||||
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
client_id: 'test_client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
code_verifier: 'test_verifier',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const tokens = await response.json();
|
||||
expect(tokens).toHaveProperty('access_token');
|
||||
expect(tokens).toHaveProperty('token_type', 'Bearer');
|
||||
expect(tokens).toHaveProperty('expires_in');
|
||||
});
|
||||
|
||||
it('handles refresh token grant', async () => {
|
||||
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'valid_refresh_token',
|
||||
client_id: 'test_client',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const tokens = await response.json();
|
||||
expect(tokens).toHaveProperty('access_token');
|
||||
expect(tokens).toHaveProperty('token_type', 'Bearer');
|
||||
});
|
||||
|
||||
it('rejects invalid grant types', async () => {
|
||||
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'invalid_grant',
|
||||
client_id: 'test_client',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const error = await response.json();
|
||||
expect(error).toHaveProperty('error', 'unsupported_grant_type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface OIDCAuthorizationParams {
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/authorize
|
||||
*
|
||||
* OIDC Authorization endpoint that initiates the authorization code flow.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/authorize.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Extract and validate required OIDC parameters
|
||||
const authParams: OIDCAuthorizationParams = {
|
||||
response_type: searchParams.get('response_type') || '',
|
||||
client_id: searchParams.get('client_id') || '',
|
||||
redirect_uri: searchParams.get('redirect_uri') || '',
|
||||
scope: searchParams.get('scope') || '',
|
||||
state: searchParams.get('state') || undefined,
|
||||
nonce: searchParams.get('nonce') || undefined,
|
||||
code_challenge: searchParams.get('code_challenge') || undefined,
|
||||
code_challenge_method: searchParams.get('code_challenge_method') || undefined,
|
||||
};
|
||||
|
||||
// Validate required parameters
|
||||
if (
|
||||
!authParams.response_type ||
|
||||
!authParams.client_id ||
|
||||
!authParams.redirect_uri ||
|
||||
!authParams.scope
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build query string for the bridge handler
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(authParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
queryParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/authorize?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Forward authentication and session headers
|
||||
Authorization: request.headers.get('Authorization') || '',
|
||||
Cookie: request.headers.get('Cookie') || '',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
Referer: request.headers.get('Referer') || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle different response types from the bridge
|
||||
if (response.status === 302) {
|
||||
// Bridge handler returned a redirect - follow it
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
return NextResponse.redirect(location);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'authorization_error',
|
||||
error_description: 'Authorization request failed',
|
||||
}));
|
||||
|
||||
console.error('OIDC Authorization error:', response.status, errorData);
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Return the authorization response with appropriate headers
|
||||
return NextResponse.json(responseData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC Authorization proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during authorization',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/authorize
|
||||
*
|
||||
* Handle authorization requests sent via POST (form-encoded).
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extract parameters from form data
|
||||
const authParams: OIDCAuthorizationParams = {
|
||||
response_type: formData.get('response_type')?.toString() || '',
|
||||
client_id: formData.get('client_id')?.toString() || '',
|
||||
redirect_uri: formData.get('redirect_uri')?.toString() || '',
|
||||
scope: formData.get('scope')?.toString() || '',
|
||||
state: formData.get('state')?.toString() || undefined,
|
||||
nonce: formData.get('nonce')?.toString() || undefined,
|
||||
code_challenge: formData.get('code_challenge')?.toString() || undefined,
|
||||
code_challenge_method: formData.get('code_challenge_method')?.toString() || undefined,
|
||||
};
|
||||
|
||||
// Validate required parameters
|
||||
if (
|
||||
!authParams.response_type ||
|
||||
!authParams.client_id ||
|
||||
!authParams.redirect_uri ||
|
||||
!authParams.scope
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Forward as form data to the bridge handler
|
||||
const bridgeFormData = new FormData();
|
||||
Object.entries(authParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
bridgeFormData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/authorize`, {
|
||||
method: 'POST',
|
||||
body: bridgeFormData,
|
||||
headers: {
|
||||
// Forward authentication and session headers
|
||||
Authorization: request.headers.get('Authorization') || '',
|
||||
Cookie: request.headers.get('Cookie') || '',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
Referer: request.headers.get('Referer') || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle redirect response
|
||||
if (response.status === 302) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
return NextResponse.redirect(location);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'authorization_error',
|
||||
error_description: 'Authorization request failed',
|
||||
}));
|
||||
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
return NextResponse.json(responseData);
|
||||
} catch (error) {
|
||||
console.error('OIDC Authorization POST proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during authorization',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/authorize
|
||||
*
|
||||
* Handle preflight CORS requests for the authorization endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface JWK {
|
||||
kty: string;
|
||||
use?: string;
|
||||
kid: string;
|
||||
alg?: string;
|
||||
n?: string; // RSA modulus
|
||||
e?: string; // RSA exponent
|
||||
x?: string; // EC x coordinate
|
||||
y?: string; // EC y coordinate
|
||||
crv?: string; // EC curve
|
||||
}
|
||||
|
||||
interface JWKSet {
|
||||
keys: JWK[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/jwks
|
||||
*
|
||||
* OIDC JSON Web Key Set (JWKS) endpoint that returns public keys used for token verification.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/jwks.
|
||||
* The returned keys are used by relying parties to verify JWT tokens issued by this OIDC provider.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/jwks`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('OIDC JWKS error:', response.status, response.statusText);
|
||||
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to retrieve JSON Web Key Set',
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const jwks: JWKSet = await response.json();
|
||||
|
||||
// Validate JWKS structure
|
||||
if (!jwks || !Array.isArray(jwks.keys)) {
|
||||
console.error('Invalid JWKS response structure:', jwks);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Invalid JWKS response from server',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each key in the set
|
||||
const validKeys = jwks.keys.filter((key: JWK) => {
|
||||
// Basic validation for required JWK fields
|
||||
if (!key.kty || !key.kid) {
|
||||
console.warn('Invalid JWK missing required fields:', key);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate key type specific fields
|
||||
if (key.kty === 'RSA' && (!key.n || !key.e)) {
|
||||
console.warn('Invalid RSA JWK missing n or e:', key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.kty === 'EC' && (!key.x || !key.y || !key.crv)) {
|
||||
console.warn('Invalid EC JWK missing x, y, or crv:', key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validatedJWKS: JWKSet = {
|
||||
keys: validKeys,
|
||||
};
|
||||
|
||||
// Return the JWKS with appropriate headers
|
||||
return NextResponse.json(validatedJWKS, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// JWKS can be cached longer since keys don't change frequently
|
||||
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=43200',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
// Security headers for key endpoint
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC JWKS proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during JWKS retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/jwks
|
||||
*
|
||||
* Handle preflight CORS requests for the JWKS endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/jwks
|
||||
*
|
||||
* Return method not allowed for POST requests to JWKS endpoint.
|
||||
*/
|
||||
export async function POST(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'method_not_allowed',
|
||||
error_description: 'POST method not allowed for JWKS endpoint. Use GET.',
|
||||
},
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'GET, OPTIONS',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface OIDCTokenRequest {
|
||||
grant_type: string;
|
||||
code?: string;
|
||||
redirect_uri?: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
code_verifier?: string;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
interface OIDCTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/token
|
||||
*
|
||||
* OIDC Token endpoint that exchanges authorization codes for access tokens.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/token.
|
||||
* Supports authorization_code, refresh_token, and client_credentials grant types.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
let tokenRequest: OIDCTokenRequest;
|
||||
|
||||
// Parse request based on content type
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
tokenRequest = {
|
||||
grant_type: formData.get('grant_type')?.toString() || '',
|
||||
code: formData.get('code')?.toString() || undefined,
|
||||
redirect_uri: formData.get('redirect_uri')?.toString() || undefined,
|
||||
client_id: formData.get('client_id')?.toString() || '',
|
||||
client_secret: formData.get('client_secret')?.toString() || undefined,
|
||||
code_verifier: formData.get('code_verifier')?.toString() || undefined,
|
||||
refresh_token: formData.get('refresh_token')?.toString() || undefined,
|
||||
};
|
||||
} else if (contentType.includes('application/json')) {
|
||||
tokenRequest = await request.json();
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Content-Type must be application/x-www-form-urlencoded or application/json',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing required parameters: grant_type and client_id',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate grant type specific parameters
|
||||
if (tokenRequest.grant_type === 'authorization_code') {
|
||||
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters for authorization_code grant: code and redirect_uri',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (tokenRequest.grant_type === 'refresh_token') {
|
||||
if (!tokenRequest.refresh_token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing required parameter for refresh_token grant: refresh_token',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare form data for the bridge handler (OIDC typically uses form encoding)
|
||||
const bridgeFormData = new FormData();
|
||||
Object.entries(tokenRequest).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
bridgeFormData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/token`, {
|
||||
method: 'POST',
|
||||
body: bridgeFormData,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Forward client authentication headers
|
||||
Authorization: request.headers.get('Authorization') || '',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'Token request failed',
|
||||
};
|
||||
}
|
||||
|
||||
console.error('OIDC Token error:', response.status, errorData);
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const tokenResponse: OIDCTokenResponse = await response.json();
|
||||
|
||||
// Return the token response with appropriate headers
|
||||
return NextResponse.json(tokenResponse, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC Token proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during token exchange',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/token
|
||||
*
|
||||
* Handle preflight CORS requests for the token endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/token
|
||||
*
|
||||
* Return method not allowed for GET requests to token endpoint.
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'GET method not allowed for token endpoint. Use POST.',
|
||||
},
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST, OPTIONS',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface OIDCUserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
did?: string;
|
||||
vault_id?: string;
|
||||
updated_at?: number;
|
||||
claims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/userinfo
|
||||
*
|
||||
* OIDC UserInfo endpoint that returns user information for a valid access token.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/userinfo.
|
||||
* Requires a valid Bearer token in the Authorization header.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
// Extract access token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_token',
|
||||
error_description:
|
||||
'Missing or invalid Authorization header. Expected format: Bearer <access_token>',
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Forward the Authorization header with the access token
|
||||
Authorization: authHeader,
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
// Handle different error statuses
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
errorData = {
|
||||
error: 'invalid_token',
|
||||
error_description: 'Access token is invalid or expired',
|
||||
};
|
||||
break;
|
||||
case 403:
|
||||
errorData = {
|
||||
error: 'insufficient_scope',
|
||||
error_description: 'Access token does not have sufficient scope',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'UserInfo request failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.error('OIDC UserInfo error:', response.status, errorData);
|
||||
|
||||
const responseHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
// Add WWW-Authenticate header for 401 responses
|
||||
if (response.status === 401) {
|
||||
responseHeaders['WWW-Authenticate'] = 'Bearer realm="OIDC UserInfo", error="invalid_token"';
|
||||
}
|
||||
|
||||
return NextResponse.json(errorData, {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const userInfo: OIDCUserInfo = await response.json();
|
||||
|
||||
// Return the user information with appropriate headers
|
||||
return NextResponse.json(userInfo, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC UserInfo proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during userinfo retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/userinfo
|
||||
*
|
||||
* OIDC UserInfo endpoint that accepts POST requests with access token in form data.
|
||||
* This is an alternative method for clients that prefer POST over GET.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
let accessToken: string | null = null;
|
||||
|
||||
// Extract access token from different sources
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.substring(7);
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
accessToken = formData.get('access_token')?.toString() || null;
|
||||
} else if (contentType.includes('application/json')) {
|
||||
const body = await request.json();
|
||||
accessToken = body.access_token || null;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_token',
|
||||
error_description: 'Access token required in Authorization header or request body',
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
|
||||
method: 'GET', // Bridge handler expects GET
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'UserInfo request failed',
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const userInfo: OIDCUserInfo = await response.json();
|
||||
return NextResponse.json(userInfo, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC UserInfo POST proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during userinfo retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/userinfo
|
||||
*
|
||||
* Handle preflight CORS requests for the userinfo endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ShieldCheckIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface OAuthGrant {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientLogo?: string;
|
||||
scopes: string[];
|
||||
capabilities: UCANCapability[];
|
||||
grantedAt: string;
|
||||
lastUsed: string;
|
||||
expiresAt?: string;
|
||||
status: 'active' | 'expired' | 'revoked';
|
||||
}
|
||||
|
||||
interface UCANCapability {
|
||||
action: string;
|
||||
resource: string;
|
||||
caveats?: Record<string, any>;
|
||||
}
|
||||
|
||||
export default function CapabilitiesPage() {
|
||||
const [grants, setGrants] = useState<OAuthGrant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGrant, setSelectedGrant] = useState<OAuthGrant | null>(null);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGrants();
|
||||
}, []);
|
||||
|
||||
const fetchGrants = async () => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
const mockGrants: OAuthGrant[] = [
|
||||
{
|
||||
id: 'grant_1',
|
||||
clientId: 'example-app',
|
||||
clientName: 'Example Application',
|
||||
scopes: ['vault:read', 'vault:write', 'profile'],
|
||||
capabilities: [
|
||||
{ action: 'read', resource: 'vault:*' },
|
||||
{ action: 'write', resource: 'vault:*' },
|
||||
{ action: 'read', resource: 'profile:*' },
|
||||
],
|
||||
grantedAt: '2024-01-15T10:00:00Z',
|
||||
lastUsed: '2024-01-20T15:30:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'grant_2',
|
||||
clientId: 'defi-wallet',
|
||||
clientName: 'DeFi Wallet',
|
||||
scopes: ['dwn:read', 'svc:register'],
|
||||
capabilities: [
|
||||
{ action: 'read', resource: 'dwn:*' },
|
||||
{ action: 'register', resource: 'svc:*' },
|
||||
],
|
||||
grantedAt: '2024-01-10T08:00:00Z',
|
||||
lastUsed: '2024-01-18T12:00:00Z',
|
||||
expiresAt: '2024-02-10T08:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
setGrants(mockGrants);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch grants:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeGrant = async (grantId: string) => {
|
||||
setRevoking(grantId);
|
||||
try {
|
||||
// TODO: Implement actual revocation API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
setGrants((prev) =>
|
||||
prev.map((grant) =>
|
||||
grant.id === grantId ? { ...grant, status: 'revoked' as const } : grant
|
||||
)
|
||||
);
|
||||
|
||||
if (selectedGrant?.id === grantId) {
|
||||
setSelectedGrant(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke grant:', error);
|
||||
} finally {
|
||||
setRevoking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: OAuthGrant['status']) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'expired':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'revoked':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-1" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">OAuth Capabilities</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage applications and services that have access to your account
|
||||
</p>
|
||||
</div>
|
||||
<ShieldCheckIcon className="w-10 h-10 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : grants.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<ShieldCheckIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Active Grants</h3>
|
||||
<p className="text-gray-600">
|
||||
You haven't granted any applications access to your account yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Grants List */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{grants.map((grant) => (
|
||||
<div
|
||||
key={grant.id}
|
||||
className={`bg-white rounded-lg shadow p-6 cursor-pointer transition-all hover:shadow-lg ${
|
||||
selectedGrant?.id === grant.id ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedGrant(grant)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
{grant.clientLogo ? (
|
||||
<img
|
||||
src={grant.clientLogo}
|
||||
alt={grant.clientName}
|
||||
className="w-10 h-10 rounded-lg mr-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-lg mr-3 flex items-center justify-center">
|
||||
<span className="text-gray-600 font-semibold">
|
||||
{grant.clientName[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{grant.clientName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">Client ID: {grant.clientId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{grant.scopes.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p>Granted: {formatDate(grant.grantedAt)}</p>
|
||||
<p>Last used: {formatDate(grant.lastUsed)}</p>
|
||||
{grant.expiresAt && <p>Expires: {formatDate(grant.expiresAt)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end ml-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(grant.status)}`}
|
||||
>
|
||||
{grant.status}
|
||||
</span>
|
||||
|
||||
{grant.status === 'active' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
revokeGrant(grant.id);
|
||||
}}
|
||||
disabled={revoking === grant.id}
|
||||
className="mt-4 inline-flex items-center px-3 py-1 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||
>
|
||||
{revoking === grant.id ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-700" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="w-4 h-4 mr-1" />
|
||||
Revoke
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grant Details */}
|
||||
{selectedGrant && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Capability Details</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">UCAN Capabilities</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedGrant.capabilities.map((cap, index) => (
|
||||
<div key={index} className="p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{cap.action}</span>
|
||||
<span className="text-gray-600">{cap.resource}</span>
|
||||
</div>
|
||||
{cap.caveats && Object.keys(cap.caveats).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Caveats: {JSON.stringify(cap.caveats)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Delegation Chain</h4>
|
||||
<button className="inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
|
||||
View full delegation chain
|
||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Audit Log</h4>
|
||||
<p className="text-sm text-gray-600">View all activities for this grant</p>
|
||||
<button className="mt-2 inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
|
||||
View audit log
|
||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { Button, ErrorAlert } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, isAuthenticated, isLoading, error, logout } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null; // Will redirect to login
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Highway Dashboard</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
Welcome, {user.displayName || user.username}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={handleLogout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{error && (
|
||||
<div className="mb-6">
|
||||
<ErrorAlert message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Account Information</h2>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
Your account details and authentication status
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-6">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Username</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{user.username}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Display Name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{user.displayName || 'Not set'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Account Created</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Authentication Method</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
WebAuthn Passkey
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
Security Features
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Passwordless Authentication</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Biometric Verification</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Phishing Resistant</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Hardware Security Key Support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional app-specific styles */
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Highway - Sonr Authentication Gateway',
|
||||
description:
|
||||
'Passwordless authentication using WebAuthn passkeys for the Sonr blockchain ecosystem',
|
||||
keywords: ['webauthn', 'passkey', 'authentication', 'sonr', 'blockchain'],
|
||||
authors: [{ name: 'Sonr' }],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useWebAuthn } from '@/hooks/useWebAuthn';
|
||||
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null);
|
||||
const { authenticateUser, isLoading, error, clearError } = useWebAuthn();
|
||||
const router = useRouter();
|
||||
|
||||
// Check WebAuthn support on component mount
|
||||
useState(() => {
|
||||
const checkSupport = async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const supported =
|
||||
window.PublicKeyCredential &&
|
||||
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
|
||||
'function';
|
||||
setIsSupported(supported);
|
||||
}
|
||||
};
|
||||
checkSupport();
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
if (!username.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await authenticateUser(username.trim());
|
||||
if (success) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (_err) {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isSupported === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
WebAuthn Not Supported
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
|
||||
Firefox, Safari, or Edge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100">
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Authentication Successful Icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Use your passkey to securely access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && <ErrorAlert message={error} onDismiss={clearError} />}
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoComplete="username"
|
||||
helpText="Enter the username you registered with"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
disabled={!username.trim() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Signing In...' : 'Sign In with Passkey'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<a href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Create one here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 text-gray-500">Secure & Simple</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-600 space-y-2">
|
||||
<p>• No passwords to type or remember</p>
|
||||
<p>• Authenticate with your device's biometrics</p>
|
||||
<p>• Protected against phishing and breaches</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
|
||||
import { AlertCircle, Check, Shield } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
interface AuthorizePageProps {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}
|
||||
|
||||
function AuthorizeContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
|
||||
|
||||
// Extract OAuth parameters
|
||||
const clientId = searchParams.get('client_id');
|
||||
const redirectUri = searchParams.get('redirect_uri');
|
||||
const responseType = searchParams.get('response_type');
|
||||
const scope = searchParams.get('scope');
|
||||
const state = searchParams.get('state');
|
||||
const codeChallenge = searchParams.get('code_challenge');
|
||||
const codeChallengeMethod = searchParams.get('code_challenge_method');
|
||||
|
||||
// Get client information (in production, fetch from server)
|
||||
const clientInfo = React.useMemo(() => {
|
||||
// Mock client data - replace with actual client registry lookup
|
||||
const clients: Record<string, { name: string; logo?: string; trusted: boolean }> = {
|
||||
dev_client_123: { name: 'Development Client', trusted: true },
|
||||
example_app: { name: 'Example Application', trusted: false },
|
||||
};
|
||||
|
||||
return clients[clientId || ''] || { name: 'Unknown Application', trusted: false };
|
||||
}, [clientId]);
|
||||
|
||||
// Check if user is authenticated
|
||||
React.useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// Check for existing session
|
||||
const token = localStorage.getItem('sonr_auth_token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Validate request parameters
|
||||
React.useEffect(() => {
|
||||
if (!clientId || !redirectUri || !responseType) {
|
||||
setError('Missing required OAuth parameters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseType !== 'code' && responseType !== 'token') {
|
||||
setError('Invalid response type. Only "code" and "token" are supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate PKCE for public clients
|
||||
if (!codeChallenge && responseType === 'code') {
|
||||
setError('PKCE code challenge is required for public clients');
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
||||
setError('Only S256 code challenge method is supported');
|
||||
return;
|
||||
}
|
||||
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
|
||||
|
||||
// Handle authorization approval
|
||||
const handleApprove = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login with return URL
|
||||
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
|
||||
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
const response = await fetch('/api/oauth/authorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
approved: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error_description || 'Authorization failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Redirect to client with authorization code
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
if (responseType === 'code') {
|
||||
redirectUrl.searchParams.set('code', result.code);
|
||||
} else {
|
||||
// Implicit flow - add token to fragment
|
||||
const fragment = new URLSearchParams({
|
||||
access_token: result.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: result.expires_in.toString(),
|
||||
scope: scope || '',
|
||||
});
|
||||
if (state) fragment.set('state', state);
|
||||
redirectUrl.hash = fragment.toString();
|
||||
}
|
||||
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl.toString();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authorization failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
clientId,
|
||||
redirectUri,
|
||||
responseType,
|
||||
scope,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
searchParams,
|
||||
router,
|
||||
]);
|
||||
|
||||
// Handle denial
|
||||
const handleDeny = React.useCallback(() => {
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
redirectUrl.searchParams.set('error', 'access_denied');
|
||||
redirectUrl.searchParams.set('error_description', 'User denied authorization');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
}, [redirectUri, state]);
|
||||
|
||||
// Parse requested scopes
|
||||
const requestedScopes = React.useMemo(() => {
|
||||
if (!scope) return [];
|
||||
|
||||
const scopeDescriptions: Record<
|
||||
string,
|
||||
{ title: string; description: string; icon: React.ReactNode }
|
||||
> = {
|
||||
openid: {
|
||||
title: 'Basic Profile',
|
||||
description: 'Your Sonr ID and basic profile information',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile Information',
|
||||
description: 'Your name, picture, and other profile details',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:read': {
|
||||
title: 'Read Vault Data',
|
||||
description: 'Read access to your encrypted vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:write': {
|
||||
title: 'Write Vault Data',
|
||||
description: 'Create and modify data in your vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:sign': {
|
||||
title: 'Sign with Vault Keys',
|
||||
description: 'Sign transactions and messages with your vault keys',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'service:manage': {
|
||||
title: 'Manage Services',
|
||||
description: 'Register and manage services on your behalf',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
return scope.split(' ').map((s) => ({
|
||||
scope: s,
|
||||
...(scopeDescriptions[s] || {
|
||||
title: s,
|
||||
description: `Access to ${s}`,
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
}),
|
||||
}));
|
||||
}, [scope]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Authorization Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{clientInfo.logo && (
|
||||
<img src={clientInfo.logo} alt={clientInfo.name} className="h-10 w-10 rounded" />
|
||||
)}
|
||||
<div>
|
||||
<CardTitle>{clientInfo.name}</CardTitle>
|
||||
<CardDescription>wants to access your Sonr account</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{clientInfo.trusted && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
Verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{!isAuthenticated && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You need to sign in to continue with authorization
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">This application will be able to:</p>
|
||||
<div className="space-y-2">
|
||||
{requestedScopes.map(({ scope, title, description, icon }) => (
|
||||
<div key={scope} className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="mt-0.5">{icon}</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By authorizing, you allow this application to access your information in accordance
|
||||
with its terms of service and privacy policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
|
||||
Deny
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={isLoading} className="flex-1">
|
||||
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthorizePage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AuthorizeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
|
||||
import { AlertCircle, Check, Shield } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
interface ClientBranding {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
logo?: string;
|
||||
verified: boolean;
|
||||
theme?: {
|
||||
primaryColor?: string;
|
||||
accentColor?: string;
|
||||
backgroundColor?: string;
|
||||
cardBackground?: string;
|
||||
borderRadius?: string;
|
||||
fontFamily?: string;
|
||||
};
|
||||
customCSS?: string;
|
||||
}
|
||||
|
||||
export default function ThemedAuthorizePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
|
||||
|
||||
// Extract OAuth parameters
|
||||
const clientId = searchParams.get('client_id');
|
||||
const redirectUri = searchParams.get('redirect_uri');
|
||||
const responseType = searchParams.get('response_type');
|
||||
const scope = searchParams.get('scope');
|
||||
const state = searchParams.get('state');
|
||||
const codeChallenge = searchParams.get('code_challenge');
|
||||
const codeChallengeMethod = searchParams.get('code_challenge_method');
|
||||
|
||||
// Get client branding information (in production, fetch from server)
|
||||
const clientBranding = React.useMemo<ClientBranding>(() => {
|
||||
// Mock client data with branding - replace with actual client registry lookup
|
||||
const clients: Record<string, ClientBranding> = {
|
||||
branded_app: {
|
||||
id: 'branded_app',
|
||||
name: 'Branded Application',
|
||||
logo: '/logos/branded-app.svg',
|
||||
verified: true,
|
||||
theme: {
|
||||
primaryColor: '#4F46E5',
|
||||
accentColor: '#7C3AED',
|
||||
backgroundColor: '#F9FAFB',
|
||||
cardBackground: '#FFFFFF',
|
||||
borderRadius: '12px',
|
||||
fontFamily: '"Inter", system-ui, sans-serif',
|
||||
},
|
||||
customCSS: `
|
||||
.authorize-card {
|
||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
.scope-item {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
`,
|
||||
},
|
||||
minimal_app: {
|
||||
id: 'minimal_app',
|
||||
name: 'Minimal Application',
|
||||
verified: false,
|
||||
theme: {
|
||||
primaryColor: '#000000',
|
||||
accentColor: '#666666',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cardBackground: '#FAFAFA',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
default: {
|
||||
id: clientId || 'unknown',
|
||||
name: 'Unknown Application',
|
||||
verified: false,
|
||||
},
|
||||
};
|
||||
|
||||
return clients[clientId || ''] || clients.default;
|
||||
}, [clientId]);
|
||||
|
||||
// Apply custom theme
|
||||
React.useEffect(() => {
|
||||
if (clientBranding.theme) {
|
||||
const theme = clientBranding.theme;
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme.primaryColor) {
|
||||
root.style.setProperty('--brand-primary', theme.primaryColor);
|
||||
}
|
||||
if (theme.accentColor) {
|
||||
root.style.setProperty('--brand-accent', theme.accentColor);
|
||||
}
|
||||
if (theme.backgroundColor) {
|
||||
root.style.setProperty('--brand-background', theme.backgroundColor);
|
||||
}
|
||||
if (theme.cardBackground) {
|
||||
root.style.setProperty('--brand-card', theme.cardBackground);
|
||||
}
|
||||
if (theme.borderRadius) {
|
||||
root.style.setProperty('--brand-radius', theme.borderRadius);
|
||||
}
|
||||
if (theme.fontFamily) {
|
||||
root.style.setProperty('--brand-font', theme.fontFamily);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom CSS if provided
|
||||
if (clientBranding.customCSS) {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = clientBranding.customCSS;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(styleElement);
|
||||
};
|
||||
}
|
||||
}, [clientBranding]);
|
||||
|
||||
// Check if user is authenticated
|
||||
React.useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('sonr_auth_token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Validate request parameters
|
||||
React.useEffect(() => {
|
||||
if (!clientId || !redirectUri || !responseType) {
|
||||
setError('Missing required OAuth parameters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseType !== 'code' && responseType !== 'token') {
|
||||
setError('Invalid response type. Only "code" and "token" are supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!codeChallenge && responseType === 'code') {
|
||||
setError('PKCE code challenge is required for public clients');
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
||||
setError('Only S256 code challenge method is supported');
|
||||
return;
|
||||
}
|
||||
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
|
||||
|
||||
// Handle authorization approval
|
||||
const handleApprove = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!isAuthenticated) {
|
||||
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
|
||||
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/oauth/authorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
approved: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error_description || 'Authorization failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
if (responseType === 'code') {
|
||||
redirectUrl.searchParams.set('code', result.code);
|
||||
} else {
|
||||
const fragment = new URLSearchParams({
|
||||
access_token: result.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: result.expires_in.toString(),
|
||||
scope: scope || '',
|
||||
});
|
||||
if (state) fragment.set('state', state);
|
||||
redirectUrl.hash = fragment.toString();
|
||||
}
|
||||
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl.toString();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authorization failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
clientId,
|
||||
redirectUri,
|
||||
responseType,
|
||||
scope,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
searchParams,
|
||||
router,
|
||||
]);
|
||||
|
||||
// Handle denial
|
||||
const handleDeny = React.useCallback(() => {
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
redirectUrl.searchParams.set('error', 'access_denied');
|
||||
redirectUrl.searchParams.set('error_description', 'User denied authorization');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
}, [redirectUri, state]);
|
||||
|
||||
// Parse requested scopes with custom icons
|
||||
const requestedScopes = React.useMemo(() => {
|
||||
if (!scope) return [];
|
||||
|
||||
const scopeDescriptions: Record<
|
||||
string,
|
||||
{ title: string; description: string; icon: React.ReactNode }
|
||||
> = {
|
||||
openid: {
|
||||
title: 'Basic Profile',
|
||||
description: 'Your Sonr ID and basic profile information',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile Information',
|
||||
description: 'Your name, picture, and other profile details',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:read': {
|
||||
title: 'Read Vault Data',
|
||||
description: 'Read access to your encrypted vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:write': {
|
||||
title: 'Write Vault Data',
|
||||
description: 'Create and modify data in your vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:sign': {
|
||||
title: 'Sign with Vault Keys',
|
||||
description: 'Sign transactions and messages with your vault keys',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'service:manage': {
|
||||
title: 'Manage Services',
|
||||
description: 'Register and manage services on your behalf',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
return scope.split(' ').map((s) => ({
|
||||
scope: s,
|
||||
...(scopeDescriptions[s] || {
|
||||
title: s,
|
||||
description: `Access to ${s}`,
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
}),
|
||||
}));
|
||||
}, [scope]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Authorization Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center p-4"
|
||||
style={{
|
||||
background: clientBranding.theme?.backgroundColor || 'var(--background)',
|
||||
fontFamily: clientBranding.theme?.fontFamily || 'inherit',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="authorize-card w-full max-w-md"
|
||||
style={{
|
||||
background: clientBranding.theme?.cardBackground || 'var(--card)',
|
||||
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{clientBranding.logo && (
|
||||
<img
|
||||
src={clientBranding.logo}
|
||||
alt={clientBranding.name}
|
||||
className="h-12 w-12 rounded"
|
||||
style={{
|
||||
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<CardTitle
|
||||
style={{
|
||||
color: clientBranding.theme?.primaryColor || 'inherit',
|
||||
}}
|
||||
>
|
||||
{clientBranding.name}
|
||||
</CardTitle>
|
||||
<CardDescription>wants to access your Sonr account</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{clientBranding.verified && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Check
|
||||
className="h-4 w-4"
|
||||
style={{ color: clientBranding.theme?.accentColor || 'rgb(34 197 94)' }}
|
||||
/>
|
||||
Verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{!isAuthenticated && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You need to sign in to continue with authorization
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">This application will be able to:</p>
|
||||
<div className="space-y-2">
|
||||
{requestedScopes.map(({ scope, title, description, icon }) => (
|
||||
<div
|
||||
key={scope}
|
||||
className="scope-item flex items-start gap-3 p-3 rounded-lg"
|
||||
style={{
|
||||
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5">{icon}</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs opacity-90">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg p-3"
|
||||
style={{
|
||||
background: clientBranding.theme?.cardBackground
|
||||
? `color-mix(in srgb, ${clientBranding.theme.cardBackground} 95%, black)`
|
||||
: 'var(--muted)',
|
||||
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
|
||||
}}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By authorizing, you allow this application to access your information in accordance
|
||||
with its terms of service and privacy policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
style={{
|
||||
background: clientBranding.theme?.primaryColor || 'var(--primary)',
|
||||
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { useSignInWithSonr } from '@sonr.io/ui';
|
||||
import { AlertCircle, CheckCircle, Loader2, XCircle } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = React.useState<'processing' | 'success' | 'error'>('processing');
|
||||
const [message, setMessage] = React.useState<string>('');
|
||||
const [userInfo, setUserInfo] = React.useState<Record<string, unknown> | null>(null);
|
||||
|
||||
// Extract OAuth callback parameters
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
|
||||
// Get stored OAuth configuration from session
|
||||
const getStoredConfig = React.useCallback(() => {
|
||||
const stored = sessionStorage.getItem('sonr_oauth_config');
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Initialize OAuth client
|
||||
const oauthConfig = React.useMemo(() => {
|
||||
const stored = getStoredConfig();
|
||||
if (stored) {
|
||||
return {
|
||||
clientId: stored.clientId,
|
||||
redirectUri: stored.redirectUri || `${window.location.origin}/oauth/callback`,
|
||||
authorizationUrl: '/oauth2/authorize',
|
||||
tokenUrl: '/oauth2/token',
|
||||
userInfoUrl: '/oauth2/userinfo',
|
||||
scopes: stored.scopes || ['openid', 'profile'],
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback config for development
|
||||
return {
|
||||
clientId: 'dev_client_123',
|
||||
redirectUri: `${window.location.origin}/oauth/callback`,
|
||||
authorizationUrl: '/oauth2/authorize',
|
||||
tokenUrl: '/oauth2/token',
|
||||
userInfoUrl: '/oauth2/userinfo',
|
||||
scopes: ['openid', 'profile'],
|
||||
};
|
||||
}, [getStoredConfig]);
|
||||
|
||||
const { handleCallback } = useSignInWithSonr(oauthConfig, {
|
||||
onSuccess: (user, token) => {
|
||||
setUserInfo(user);
|
||||
setStatus('success');
|
||||
setMessage('Authentication successful! Redirecting...');
|
||||
|
||||
// Store authentication data
|
||||
localStorage.setItem('sonr_auth_user', JSON.stringify(user));
|
||||
localStorage.setItem('sonr_auth_token', JSON.stringify(token));
|
||||
|
||||
// Get return URL from session storage
|
||||
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url');
|
||||
sessionStorage.removeItem('sonr_oauth_return_url');
|
||||
sessionStorage.removeItem('sonr_oauth_config');
|
||||
|
||||
// Redirect to return URL or dashboard
|
||||
setTimeout(() => {
|
||||
if (returnUrl) {
|
||||
// If it's an external URL, use postMessage to communicate
|
||||
if (returnUrl.startsWith('http')) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'sonr_auth_success',
|
||||
user,
|
||||
token,
|
||||
},
|
||||
new URL(returnUrl).origin
|
||||
);
|
||||
window.close();
|
||||
} else {
|
||||
router.push(returnUrl);
|
||||
}
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatus('error');
|
||||
setMessage(err.message || 'Authentication failed');
|
||||
},
|
||||
});
|
||||
|
||||
// Handle OAuth callback
|
||||
React.useEffect(() => {
|
||||
const processCallback = async () => {
|
||||
// Check for OAuth errors first
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
setMessage(errorDescription || `OAuth error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
const storedState = sessionStorage.getItem('sonr_oauth_state');
|
||||
if (state && storedState && state !== storedState) {
|
||||
setStatus('error');
|
||||
setMessage('Invalid state parameter. Possible CSRF attack.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process authorization code
|
||||
if (code) {
|
||||
try {
|
||||
await handleCallback(window.location.href);
|
||||
} catch (err) {
|
||||
console.error('Callback processing error:', err);
|
||||
setStatus('error');
|
||||
setMessage(err instanceof Error ? err.message : 'Failed to process callback');
|
||||
}
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('No authorization code received');
|
||||
}
|
||||
};
|
||||
|
||||
processCallback();
|
||||
}, [code, state, error, errorDescription, handleCallback]);
|
||||
|
||||
// Handle retry
|
||||
const handleRetry = () => {
|
||||
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url') || '/dashboard';
|
||||
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
// Handle close for popup mode
|
||||
const handleClose = () => {
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: 'sonr_auth_cancelled',
|
||||
},
|
||||
'*'
|
||||
);
|
||||
window.close();
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Processing Authentication
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
Authentication Successful
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
Authentication Failed
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'processing' && 'Please wait while we complete your authentication...'}
|
||||
{status === 'success' && 'You have been successfully authenticated'}
|
||||
{status === 'error' && 'There was a problem with your authentication'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status message */}
|
||||
{message && (
|
||||
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* User info display on success */}
|
||||
{status === 'success' && userInfo && (
|
||||
<div className="space-y-2 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-sm font-medium">Welcome back!</p>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{userInfo.name && <p>Name: {userInfo.name}</p>}
|
||||
{userInfo.email && <p>Email: {userInfo.email}</p>}
|
||||
{userInfo.did && <p className="font-mono text-xs break-all">DID: {userInfo.did}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error details */}
|
||||
{status === 'error' && error && (
|
||||
<div className="space-y-1 p-3 rounded-lg bg-destructive/10 text-sm">
|
||||
<p className="font-medium">Error Code: {error}</p>
|
||||
{errorDescription && <p className="text-muted-foreground">{errorDescription}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading spinner */}
|
||||
{status === 'processing' && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{status === 'error' && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRetry} className="flex-1">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success redirect notice */}
|
||||
{status === 'success' && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>Redirecting you to the application...</p>
|
||||
<div className="mt-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { Checkbox } from '@sonr.io/ui';
|
||||
import { Label } from '@sonr.io/ui';
|
||||
import { AlertCircle, Database, Info, Key, Shield, Wrench } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
interface ConsentScope {
|
||||
scope: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
required: boolean;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
function ConsentContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<string>>(new Set());
|
||||
const [rememberChoice, setRememberChoice] = React.useState(false);
|
||||
|
||||
// Extract OAuth parameters
|
||||
const clientId = searchParams.get('client_id');
|
||||
const redirectUri = searchParams.get('redirect_uri');
|
||||
const requestedScopes = searchParams.get('scope')?.split(' ') || [];
|
||||
const state = searchParams.get('state');
|
||||
const authCode = searchParams.get('auth_code'); // Internal auth code for consent
|
||||
|
||||
// Define available scopes with UCAN capability mappings
|
||||
const scopeDefinitions: ConsentScope[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
scope: 'openid',
|
||||
title: 'Basic Identity',
|
||||
description: 'Access to your Sonr DID and basic authentication info',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
required: true,
|
||||
capabilities: ['did:read'],
|
||||
},
|
||||
{
|
||||
scope: 'profile',
|
||||
title: 'Profile Information',
|
||||
description: 'Read your name, picture, and public profile details',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['profile:read'],
|
||||
},
|
||||
{
|
||||
scope: 'vault:read',
|
||||
title: 'Read Vault Data',
|
||||
description: 'Read encrypted data stored in your personal vault',
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['vault:read', 'vault:list'],
|
||||
},
|
||||
{
|
||||
scope: 'vault:write',
|
||||
title: 'Modify Vault Data',
|
||||
description: 'Create, update, and organize data in your vault',
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['vault:write', 'vault:create', 'vault:update'],
|
||||
},
|
||||
{
|
||||
scope: 'vault:sign',
|
||||
title: 'Sign Transactions',
|
||||
description: 'Sign messages and transactions using your vault keys',
|
||||
icon: <Key className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['vault:sign', 'tx:sign'],
|
||||
},
|
||||
{
|
||||
scope: 'service:manage',
|
||||
title: 'Manage Services',
|
||||
description: 'Register and manage decentralized services on your behalf',
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['service:create', 'service:update', 'service:delete'],
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Filter to only requested scopes
|
||||
const availableScopes = React.useMemo(() => {
|
||||
return scopeDefinitions.filter((def) => requestedScopes.includes(def.scope));
|
||||
}, [scopeDefinitions, requestedScopes]);
|
||||
|
||||
// Initialize selected scopes with required ones
|
||||
React.useEffect(() => {
|
||||
const required = new Set(availableScopes.filter((s) => s.required).map((s) => s.scope));
|
||||
setSelectedScopes(required);
|
||||
}, [availableScopes]);
|
||||
|
||||
// Validate request
|
||||
React.useEffect(() => {
|
||||
if (!clientId || !redirectUri || !authCode) {
|
||||
setError('Invalid consent request. Missing required parameters.');
|
||||
}
|
||||
}, [clientId, redirectUri, authCode]);
|
||||
|
||||
// Handle scope toggle
|
||||
const toggleScope = (scope: string, required: boolean) => {
|
||||
if (required) return; // Can't toggle required scopes
|
||||
|
||||
setSelectedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(scope)) {
|
||||
next.delete(scope);
|
||||
} else {
|
||||
next.add(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle consent approval
|
||||
const handleApprove = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Submit consent decision
|
||||
const response = await fetch('/api/oauth/consent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
auth_code: authCode,
|
||||
client_id: clientId,
|
||||
approved_scopes: Array.from(selectedScopes),
|
||||
remember: rememberChoice,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error_description || 'Consent submission failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Redirect back to authorization endpoint with consent token
|
||||
const authUrl = new URL('/oauth/authorize', window.location.origin);
|
||||
authUrl.searchParams.set('client_id', clientId!);
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri!);
|
||||
authUrl.searchParams.set('scope', Array.from(selectedScopes).join(' '));
|
||||
authUrl.searchParams.set('consent_token', result.consent_token);
|
||||
if (state) {
|
||||
authUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
router.push(authUrl.pathname + authUrl.search);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Consent submission failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle consent denial
|
||||
const handleDeny = () => {
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
redirectUrl.searchParams.set('error', 'consent_required');
|
||||
redirectUrl.searchParams.set('error_description', 'User denied consent for requested scopes');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
};
|
||||
|
||||
// Calculate total capabilities being granted
|
||||
const totalCapabilities = React.useMemo(() => {
|
||||
const caps = new Set<string>();
|
||||
availableScopes.forEach((scope) => {
|
||||
if (selectedScopes.has(scope.scope) && scope.capabilities) {
|
||||
scope.capabilities.forEach((cap) => caps.add(cap));
|
||||
}
|
||||
});
|
||||
return Array.from(caps);
|
||||
}, [availableScopes, selectedScopes]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Consent Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Review Permissions</CardTitle>
|
||||
<CardDescription>Choose which permissions to grant this application</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Scope selection */}
|
||||
<div className="space-y-3">
|
||||
{availableScopes.map((scope) => (
|
||||
<div
|
||||
key={scope.scope}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
selectedScopes.has(scope.scope)
|
||||
? 'bg-primary/5 border-primary/20'
|
||||
: 'bg-muted/30 border-muted-foreground/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={scope.scope}
|
||||
checked={selectedScopes.has(scope.scope)}
|
||||
disabled={scope.required}
|
||||
onCheckedChange={() => toggleScope(scope.scope, scope.required)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor={scope.scope} className="flex items-center gap-2 cursor-pointer">
|
||||
{scope.icon}
|
||||
<span className="font-medium">{scope.title}</span>
|
||||
{scope.required && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{scope.description}</p>
|
||||
{scope.capabilities && scope.capabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{scope.capabilities.map((cap) => (
|
||||
<span key={cap} className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* UCAN capabilities summary */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>UCAN Capabilities:</strong> This will create a delegation chain granting{' '}
|
||||
{totalCapabilities.length} capabilities to the application.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Remember choice option */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="remember" className="text-sm cursor-pointer">
|
||||
Remember my choice for this application
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Privacy notice */}
|
||||
<div className="text-xs text-muted-foreground p-3 rounded-lg bg-muted/30">
|
||||
Your data remains encrypted in your vault. Applications can only access what you
|
||||
explicitly permit through these capabilities.
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading || selectedScopes.size === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading
|
||||
? 'Processing...'
|
||||
: `Grant ${selectedScopes.size} Permission${selectedScopes.size !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsentPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ConsentContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { Button, SignInWithSonr } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, isLoading } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-gray-50">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{/* Logo and Title */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100 mb-6">
|
||||
<svg
|
||||
className="h-8 w-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Security shield"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">Highway</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">Sonr Authentication Gateway</p>
|
||||
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
|
||||
Experience passwordless authentication with WebAuthn passkeys. Secure, simple, and
|
||||
seamless access to your digital identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
|
||||
<Button size="lg" onClick={() => router.push('/register')} className="px-8 py-3">
|
||||
Create Account
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/login')}
|
||||
className="px-8 py-3"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OAuth Provider Section */}
|
||||
<div className="border-t pt-8 mb-8">
|
||||
<p className="text-sm text-gray-600 mb-4">Or use Sonr as an OAuth provider</p>
|
||||
<SignInWithSonr
|
||||
clientId="demo_client"
|
||||
redirectUri={`${window.location.origin}/oauth/callback`}
|
||||
scopes={['openid', 'profile', 'vault:read']}
|
||||
onSuccess={(result) => {
|
||||
console.log('OAuth success:', result);
|
||||
router.push('/dashboard');
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('OAuth error:', error);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Lock icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Passwords</h3>
|
||||
<p className="text-gray-600">
|
||||
Authenticate using your device's built-in security features like fingerprint or face
|
||||
recognition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Lightning bolt"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Lightning Fast</h3>
|
||||
<p className="text-gray-600">
|
||||
Sign in instantly without typing passwords. One touch or glance is all it takes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-purple-100 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Shield check"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Secure by Design</h3>
|
||||
<p className="text-gray-600">
|
||||
Protected against phishing, breaches, and replay attacks with cryptographic
|
||||
security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>Powered by WebAuthn and FIDO2 standards</p>
|
||||
<p className="mt-1">Compatible with modern browsers and platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useWebAuthn } from '@/hooks/useWebAuthn';
|
||||
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null);
|
||||
const { registerUser, isLoading, error, clearError } = useWebAuthn();
|
||||
const router = useRouter();
|
||||
|
||||
// Check WebAuthn support on component mount
|
||||
useState(() => {
|
||||
const checkSupport = async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const supported =
|
||||
window.PublicKeyCredential &&
|
||||
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
|
||||
'function';
|
||||
setIsSupported(supported);
|
||||
}
|
||||
};
|
||||
checkSupport();
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
if (!username.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await registerUser(username.trim(), displayName.trim() || undefined);
|
||||
if (success) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (_err) {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isSupported === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
WebAuthn Not Supported
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
|
||||
Firefox, Safari, or Edge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Account Creation Icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Register with a passkey for secure, passwordless authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && <ErrorAlert message={error} onDismiss={clearError} />}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoComplete="username"
|
||||
helpText="Choose a unique username for your account"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Display Name (Optional)"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
autoComplete="name"
|
||||
helpText="This will be shown in your profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
disabled={!username.trim() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Creating Account...' : 'Create Account with Passkey'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Sign in here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 text-gray-500">What is a passkey?</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-600 space-y-2">
|
||||
<p>• No passwords to remember or type</p>
|
||||
<p>• Uses your device's built-in security (fingerprint, face, PIN)</p>
|
||||
<p>• More secure than traditional passwords</p>
|
||||
<p>• Works across all your devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface DelegationNode {
|
||||
id: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
capabilities: UCANCapability[];
|
||||
expiresAt?: string;
|
||||
issuedAt: string;
|
||||
signature: string;
|
||||
proofs: string[];
|
||||
children?: DelegationNode[];
|
||||
}
|
||||
|
||||
interface UCANCapability {
|
||||
action: string;
|
||||
resource: string;
|
||||
caveats?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface DelegationChainViewerProps {
|
||||
rootDelegation: DelegationNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DelegationChainViewer({
|
||||
rootDelegation,
|
||||
className = '',
|
||||
}: DelegationChainViewerProps) {
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set([rootDelegation.id]));
|
||||
|
||||
const toggleNode = (nodeId: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const formatDID = (did: string) => {
|
||||
if (did.length > 20) {
|
||||
return `${did.slice(0, 8)}...${did.slice(-8)}`;
|
||||
}
|
||||
return did;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const renderNode = (node: DelegationNode, depth = 0): JSX.Element => {
|
||||
const isExpanded = expandedNodes.has(node.id);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="relative">
|
||||
{/* Connection line for non-root nodes */}
|
||||
{depth > 0 && (
|
||||
<div
|
||||
className="absolute left-6 top-0 w-px bg-gray-300"
|
||||
style={{ height: '24px', transform: 'translateY(-24px)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`relative ${depth > 0 ? 'ml-8' : ''}`}>
|
||||
{/* Node container */}
|
||||
<div className="group relative bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
{/* Expand/collapse button */}
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => toggleNode(node.id)}
|
||||
className="absolute -left-3 top-6 w-6 h-6 bg-white border border-gray-300 rounded-full flex items-center justify-center hover:bg-gray-50"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Node content */}
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Delegation {depth > 0 ? `(Level ${depth})` : '(Root)'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">ID: {node.id.slice(0, 16)}...</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{formatDate(node.issuedAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Issuer and Audience */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500">Issuer:</span>
|
||||
<p className="font-mono text-xs mt-1">{formatDID(node.issuer)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500">Audience:</span>
|
||||
<p className="font-mono text-xs mt-1">{formatDID(node.audience)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div>
|
||||
<span className="text-xs text-gray-500">Capabilities:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{node.capabilities.map((cap, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{cap.action}:{cap.resource}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
{node.expiresAt && (
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-500">Expires:</span>
|
||||
<span
|
||||
className={`ml-2 ${new Date(node.expiresAt) < new Date() ? 'text-red-600' : 'text-gray-900'}`}
|
||||
>
|
||||
{formatDate(node.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature preview */}
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-500">Signature:</span>
|
||||
<span className="ml-2 font-mono text-gray-600">
|
||||
{node.signature.slice(0, 20)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children nodes */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="relative mt-4 space-y-4">
|
||||
{/* Vertical connection line */}
|
||||
{node.children!.map(
|
||||
(_, index) =>
|
||||
index < node.children!.length - 1 && (
|
||||
<div
|
||||
key={`line-${index}`}
|
||||
className="absolute left-6 top-6 w-px bg-gray-300"
|
||||
style={{
|
||||
height: `calc(100% - 24px)`,
|
||||
transform: 'translateY(24px)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{node.children!.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return <div className={`delegation-chain-viewer ${className}`}>{renderNode(rootDelegation)}</div>;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* WebAuthn Registration Component with Email/Tel Support
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useWebAuthn } from '../hooks/useWebAuthn';
|
||||
|
||||
interface RegistrationSuccessInfo {
|
||||
did: string;
|
||||
vaultId: string;
|
||||
assertionMethods: string[];
|
||||
}
|
||||
|
||||
export function WebAuthnRegistration() {
|
||||
const { registerUser, isLoading, error, clearError } = useWebAuthn();
|
||||
|
||||
// Form state
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [assertionType, setAssertionType] = useState<'email' | 'tel'>('email');
|
||||
const [assertionValue, setAssertionValue] = useState('');
|
||||
const [createVault, setCreateVault] = useState(true);
|
||||
|
||||
// Success state
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [registrationInfo, setRegistrationInfo] = useState<RegistrationSuccessInfo | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setSuccess(false);
|
||||
setRegistrationInfo(null);
|
||||
|
||||
try {
|
||||
const result = await registerUser(
|
||||
username,
|
||||
displayName || username,
|
||||
assertionType === 'email' ? assertionValue : undefined,
|
||||
assertionType === 'tel' ? assertionValue : undefined,
|
||||
createVault
|
||||
);
|
||||
|
||||
if (result.success && result.did && result.vaultId) {
|
||||
setSuccess(true);
|
||||
setRegistrationInfo({
|
||||
did: result.did,
|
||||
vaultId: result.vaultId,
|
||||
assertionMethods: [
|
||||
`did:sonr:${username}`,
|
||||
`did:${assertionType}:${assertionValue}`
|
||||
]
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Registration failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setUsername('');
|
||||
setDisplayName('');
|
||||
setAssertionValue('');
|
||||
setSuccess(false);
|
||||
setRegistrationInfo(null);
|
||||
clearError();
|
||||
};
|
||||
|
||||
if (success && registrationInfo) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-green-600 mb-2">
|
||||
✅ Registration Successful!
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Your decentralized identity has been created successfully.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">DID Document</h3>
|
||||
<code className="block text-xs text-gray-600 break-all">
|
||||
{registrationInfo.did}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">Vault ID</h3>
|
||||
<code className="block text-xs text-gray-600 break-all">
|
||||
{registrationInfo.vaultId}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">Assertion Methods</h3>
|
||||
<ul className="space-y-1">
|
||||
{registrationInfo.assertionMethods.map((method, index) => (
|
||||
<li key={index} className="text-sm text-gray-600">
|
||||
• {method}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded">
|
||||
<h3 className="font-semibold text-blue-700 mb-2">Authentication Method</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
WebAuthn credential successfully registered
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 p-4 rounded">
|
||||
<h3 className="font-semibold text-purple-700 mb-2">UCAN Delegation</h3>
|
||||
<p className="text-sm text-purple-600">
|
||||
Origin token for wallet admin operations created
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Register Another Account
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6">Create Your Sonr Identity</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="alice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="displayName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Display Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Alice Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Assertion Method
|
||||
</label>
|
||||
<div className="flex space-x-4 mb-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="email"
|
||||
checked={assertionType === 'email'}
|
||||
onChange={(e) => setAssertionType(e.target.value as 'email')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span>Email</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="tel"
|
||||
checked={assertionType === 'tel'}
|
||||
onChange={(e) => setAssertionType(e.target.value as 'tel')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span>Phone</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type={assertionType === 'email' ? 'email' : 'tel'}
|
||||
value={assertionValue}
|
||||
onChange={(e) => setAssertionValue(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={assertionType === 'email' ? 'alice@example.com' : '+1234567890'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createVault"
|
||||
checked={createVault}
|
||||
onChange={(e) => setCreateVault(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="createVault" className="text-sm text-gray-700">
|
||||
Create secure vault for encrypted data storage
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Creating Identity...' : 'Create Identity with WebAuthn'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">What will be created:</h3>
|
||||
<ul className="text-xs text-blue-700 space-y-1">
|
||||
<li>• W3C DID with Sonr address as controller</li>
|
||||
<li>• Two assertion methods (Sonr account + {assertionType === 'email' ? 'email' : 'phone'})</li>
|
||||
<li>• WebAuthn authentication method for passwordless login</li>
|
||||
<li>• UCAN delegation chain with validator proof</li>
|
||||
{createVault && <li>• Encrypted vault for secure data storage</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SessionHookReturn {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
logout: () => Promise<void>;
|
||||
checkSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
export function useSession(): SessionHookReturn {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isAuthenticated = user !== null;
|
||||
|
||||
const makeAuthenticatedRequest = useCallback(
|
||||
async (endpoint: string, options: RequestInit = {}) => {
|
||||
const sessionToken = localStorage.getItem('highway_session');
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error('No session token found');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Session expired or invalid
|
||||
localStorage.removeItem('highway_session');
|
||||
setUser(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const sessionToken = localStorage.getItem('highway_session');
|
||||
|
||||
if (!sessionToken) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate session by getting user profile
|
||||
const response = await makeAuthenticatedRequest('/auth/profile');
|
||||
|
||||
if (response.user) {
|
||||
setUser(response.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
localStorage.removeItem('highway_session');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Session check error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Session check failed');
|
||||
setUser(null);
|
||||
localStorage.removeItem('highway_session');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [makeAuthenticatedRequest]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Call logout endpoint to invalidate session on server
|
||||
await makeAuthenticatedRequest('/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
// Continue with local logout even if server call fails
|
||||
} finally {
|
||||
// Always clear local session
|
||||
localStorage.removeItem('highway_session');
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [makeAuthenticatedRequest]);
|
||||
|
||||
// Check session on mount and when the component is focused
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
|
||||
const handleFocus = () => {
|
||||
checkSession();
|
||||
};
|
||||
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'highway_session') {
|
||||
if (e.newValue === null) {
|
||||
setUser(null);
|
||||
} else {
|
||||
checkSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [checkSession]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
logout,
|
||||
checkSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { WebAuthnClient } from '@sonr.io/sdk';
|
||||
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
OIDCClient,
|
||||
type OIDCClientConfig,
|
||||
type OIDCSession,
|
||||
clearSession,
|
||||
isSessionValid,
|
||||
loadSession,
|
||||
saveSession,
|
||||
} from '../lib/oidc';
|
||||
import { SIOPClient, type SIOPResponse, submitSIOPResponse } from '../lib/siop';
|
||||
|
||||
interface WebAuthnHookReturn {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
session: OIDCSession | null;
|
||||
isAuthenticated: boolean;
|
||||
registerUser: (
|
||||
username: string,
|
||||
displayName?: string,
|
||||
email?: string,
|
||||
tel?: string,
|
||||
createVault?: boolean
|
||||
) => Promise<WebAuthnRegistrationResult>;
|
||||
authenticateUser: (username: string) => Promise<WebAuthnAuthenticationResult>;
|
||||
handleSIOPRequest: (requestUrl: string) => Promise<SIOPResponse>;
|
||||
submitSIOPResponse: (
|
||||
response: SIOPResponse,
|
||||
redirectUri: string,
|
||||
responseMode?: 'form_post' | 'fragment' | 'query'
|
||||
) => Promise<void>;
|
||||
initializeOIDC: (config: Partial<OIDCClientConfig>) => Promise<string>;
|
||||
handleOIDCCallback: (callbackUrl: string) => Promise<OIDCSession>;
|
||||
refreshSession: () => Promise<OIDCSession | null>;
|
||||
logout: () => void;
|
||||
getCredentials: (username: string) => Promise<WebAuthnCredential[]>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationResult {
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
did?: string;
|
||||
vaultId?: string;
|
||||
sessionId?: string;
|
||||
credential?: WebAuthnCredential;
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationResult {
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
did?: string;
|
||||
vaultId?: string;
|
||||
sessionId?: string;
|
||||
user?: UserInfo;
|
||||
}
|
||||
|
||||
interface WebAuthnCredential {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
did?: string;
|
||||
vault_id?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
options?: unknown;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
user?: UserInfo;
|
||||
did?: string;
|
||||
vaultId?: string;
|
||||
credential?: WebAuthnCredential;
|
||||
credentials?: WebAuthnCredential[];
|
||||
}
|
||||
|
||||
// Use bridge endpoints at port 8080 instead of direct 8787
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
export function useWebAuthn(): WebAuthnHookReturn {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [session, setSession] = useState<OIDCSession | null>(null);
|
||||
const [oidcClient, setOidcClient] = useState<OIDCClient | null>(null);
|
||||
const [siopClient, setSiopClient] = useState<SIOPClient | null>(null);
|
||||
|
||||
// Initialize WebAuthn client from SDK
|
||||
const webAuthnClient = useMemo(() => {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_CHAIN_API_URL || 'http://localhost:1317';
|
||||
return new WebAuthnClient(apiUrl);
|
||||
}, []);
|
||||
|
||||
// Initialize session state
|
||||
useEffect(() => {
|
||||
const savedSession = loadSession();
|
||||
if (savedSession && isSessionValid(savedSession)) {
|
||||
setSession(savedSession);
|
||||
} else if (savedSession) {
|
||||
clearSession();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const isAuthenticated = session !== null && isSessionValid(session);
|
||||
|
||||
const makeApiCall = useCallback(
|
||||
async (endpoint: string, data?: unknown, method = 'POST'): Promise<ApiResponse> => {
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
};
|
||||
|
||||
if (data && method !== 'GET') {
|
||||
config.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
// Add session headers if available
|
||||
if (session?.accessToken) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${BRIDGE_URL}${endpoint}`, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'Request failed',
|
||||
error_description: `HTTP ${response.status} ${response.statusText}`,
|
||||
}));
|
||||
throw new Error(errorData.error_description || errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
[session]
|
||||
);
|
||||
|
||||
const registerUser = useCallback(
|
||||
async (
|
||||
username: string,
|
||||
displayName?: string,
|
||||
email?: string,
|
||||
tel?: string,
|
||||
createVault = true
|
||||
): Promise<WebAuthnRegistrationResult> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use SDK WebAuthn client for registration with email/tel support
|
||||
const result = await webAuthnClient.register({
|
||||
username,
|
||||
displayName,
|
||||
email,
|
||||
tel,
|
||||
createVault,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Fetch DID document to get full user info
|
||||
let userInfo: UserInfo | undefined;
|
||||
if (result.did) {
|
||||
try {
|
||||
const { document, metadata } = await webAuthnClient.getDIDDocument(result.did);
|
||||
userInfo = {
|
||||
sub: result.did,
|
||||
name: displayName || username,
|
||||
preferred_username: username,
|
||||
email: email,
|
||||
email_verified: !!email,
|
||||
did: result.did,
|
||||
vault_id: result.vaultId,
|
||||
};
|
||||
} catch (docError) {
|
||||
console.warn('Failed to fetch DID document:', docError);
|
||||
}
|
||||
}
|
||||
|
||||
// Create OIDC session if we have user info
|
||||
if (userInfo && result.ucanToken) {
|
||||
const newSession: OIDCSession = {
|
||||
accessToken: result.ucanToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600 * 1000, // 1 hour default
|
||||
userInfo,
|
||||
};
|
||||
|
||||
saveSession(newSession);
|
||||
setSession(newSession);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: result.did?.split(':').pop(),
|
||||
did: result.did,
|
||||
vaultId: result.vaultId,
|
||||
sessionId: result.ucanToken,
|
||||
credential: result.credential,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(result.error || 'Registration failed');
|
||||
} catch (err) {
|
||||
console.error('WebAuthn registration error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[webAuthnClient]
|
||||
);
|
||||
|
||||
const authenticateUser = useCallback(
|
||||
async (username: string): Promise<WebAuthnAuthenticationResult> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use SDK WebAuthn client for authentication
|
||||
const result = await webAuthnClient.authenticate({
|
||||
username,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Fetch DID document to get full user info
|
||||
let userInfo: UserInfo | undefined;
|
||||
if (result.did) {
|
||||
try {
|
||||
const { document, metadata } = await webAuthnClient.getDIDDocument(result.did);
|
||||
userInfo = {
|
||||
sub: result.did,
|
||||
name: username,
|
||||
preferred_username: username,
|
||||
did: result.did,
|
||||
vault_id: result.vaultId,
|
||||
};
|
||||
} catch (docError) {
|
||||
console.warn('Failed to fetch DID document:', docError);
|
||||
}
|
||||
}
|
||||
|
||||
// Create OIDC session
|
||||
if (userInfo && result.sessionToken) {
|
||||
const newSession: OIDCSession = {
|
||||
accessToken: result.sessionToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600 * 1000, // 1 hour default
|
||||
userInfo,
|
||||
};
|
||||
|
||||
saveSession(newSession);
|
||||
setSession(newSession);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: result.did?.split(':').pop(),
|
||||
did: result.did,
|
||||
vaultId: result.vaultId,
|
||||
sessionId: result.sessionToken,
|
||||
user: userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(result.error || 'Authentication failed');
|
||||
} catch (err) {
|
||||
console.error('WebAuthn authentication error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[webAuthnClient]
|
||||
);
|
||||
|
||||
// Get WebAuthn credentials for a user
|
||||
const getCredentials = useCallback(
|
||||
async (username: string): Promise<WebAuthnCredential[]> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await makeApiCall(`/webauthn/credentials/${username}`, undefined, 'GET');
|
||||
return response.credentials || [];
|
||||
} catch (err) {
|
||||
console.error('Get credentials error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to get credentials');
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[makeApiCall]
|
||||
);
|
||||
|
||||
// Initialize OIDC client
|
||||
const initializeOIDC = useCallback(async (config: Partial<OIDCClientConfig>): Promise<string> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const defaultConfig: OIDCClientConfig = {
|
||||
clientId: 'sonr-auth',
|
||||
redirectUri: `${window.location.origin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
responseType: 'code',
|
||||
grantType: 'authorization_code',
|
||||
providerUrl: window.location.origin,
|
||||
usePKCE: true,
|
||||
...config,
|
||||
};
|
||||
|
||||
const client = new OIDCClient(defaultConfig);
|
||||
await client.initialize();
|
||||
setOidcClient(client);
|
||||
|
||||
const authUrl = await client.authorize();
|
||||
return authUrl;
|
||||
} catch (err) {
|
||||
console.error('OIDC initialization error:', err);
|
||||
setError(err instanceof Error ? err.message : 'OIDC initialization failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle OIDC callback
|
||||
const handleOIDCCallback = useCallback(
|
||||
async (callbackUrl: string): Promise<OIDCSession> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!oidcClient) {
|
||||
throw new Error('OIDC client not initialized');
|
||||
}
|
||||
|
||||
const newSession = await oidcClient.handleCallback(callbackUrl);
|
||||
setSession(newSession);
|
||||
return newSession;
|
||||
} catch (err) {
|
||||
console.error('OIDC callback error:', err);
|
||||
setError(err instanceof Error ? err.message : 'OIDC callback failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[oidcClient]
|
||||
);
|
||||
|
||||
// Refresh OIDC session
|
||||
const refreshSession = useCallback(async (): Promise<OIDCSession | null> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!oidcClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshedSession = await oidcClient.refreshSession();
|
||||
setSession(refreshedSession);
|
||||
return refreshedSession;
|
||||
} catch (err) {
|
||||
console.error('Session refresh error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Session refresh failed');
|
||||
setSession(null);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [oidcClient]);
|
||||
|
||||
// Handle SIOP request
|
||||
const handleSIOPRequest = useCallback(
|
||||
async (requestUrl: string): Promise<SIOPResponse> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!session?.userInfo?.did) {
|
||||
throw new Error('No DID available for SIOP authentication');
|
||||
}
|
||||
|
||||
// Initialize SIOP client if needed
|
||||
let client = siopClient;
|
||||
if (!client) {
|
||||
client = new SIOPClient(session.userInfo.did);
|
||||
await client.initialize();
|
||||
setSiopClient(client);
|
||||
}
|
||||
|
||||
const response = await client.handleSIOPRequest(requestUrl);
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error('SIOP request error:', err);
|
||||
setError(err instanceof Error ? err.message : 'SIOP request failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[session, siopClient]
|
||||
);
|
||||
|
||||
// Submit SIOP response
|
||||
const submitSIOPResponseCallback = useCallback(
|
||||
async (
|
||||
response: SIOPResponse,
|
||||
redirectUri: string,
|
||||
responseMode: 'form_post' | 'fragment' | 'query' = 'fragment'
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await submitSIOPResponse(response, redirectUri, responseMode);
|
||||
} catch (err) {
|
||||
console.error('SIOP response submission error:', err);
|
||||
setError(err instanceof Error ? err.message : 'SIOP response submission failed');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Logout and clear session
|
||||
const logout = useCallback(() => {
|
||||
clearSession();
|
||||
setSession(null);
|
||||
setOidcClient(null);
|
||||
setSiopClient(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
session,
|
||||
isAuthenticated,
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
handleSIOPRequest,
|
||||
submitSIOPResponse: submitSIOPResponseCallback,
|
||||
initializeOIDC,
|
||||
handleOIDCCallback,
|
||||
refreshSession,
|
||||
logout,
|
||||
getCredentials,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
/**
|
||||
* OpenID Connect (OIDC) Utilities
|
||||
*
|
||||
* This module provides comprehensive OIDC client utilities for the Next.js auth application.
|
||||
* It supports standard OIDC flows with PKCE, token validation, and session management.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization Code Flow with PKCE
|
||||
* - Token validation and management
|
||||
* - Discovery document fetching
|
||||
* - JWT decoding and validation
|
||||
* - Session management
|
||||
* - CORS-enabled fetch wrapper
|
||||
*/
|
||||
|
||||
// Utility function to generate random string for PKCE and nonces
|
||||
export function generateRandomString(length: number): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const randomValues = new Uint8Array(length);
|
||||
crypto.getRandomValues(randomValues);
|
||||
return Array.from(randomValues, (byte) => charset[byte % charset.length]).join('');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types and Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* OIDC Provider Configuration from Discovery Document
|
||||
*/
|
||||
export interface OIDCProviderConfig {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
jwks_uri: string;
|
||||
registration_endpoint?: string;
|
||||
scopes_supported: string[];
|
||||
response_types_supported: string[];
|
||||
response_modes_supported?: string[];
|
||||
grant_types_supported: string[];
|
||||
subject_types_supported: string[];
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
token_endpoint_auth_methods_supported?: string[];
|
||||
claims_supported?: string[];
|
||||
code_challenge_methods_supported?: string[];
|
||||
// SIOP specific fields
|
||||
subject_syntax_types_supported?: string[];
|
||||
id_token_types_supported?: string[];
|
||||
request_object_signing_alg_values_supported?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Client Configuration
|
||||
*/
|
||||
export interface OIDCClientConfig {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUri: string;
|
||||
scope: string;
|
||||
responseType: string;
|
||||
grantType: string;
|
||||
providerUrl: string;
|
||||
usePKCE: boolean;
|
||||
additionalParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCE Parameters for Authorization Code Flow
|
||||
*/
|
||||
export interface PKCEParams {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization Request Parameters
|
||||
*/
|
||||
export interface AuthorizationParams {
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
response_mode?: string;
|
||||
prompt?: string;
|
||||
max_age?: number;
|
||||
ui_locales?: string;
|
||||
claims?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Request Parameters
|
||||
*/
|
||||
export interface TokenRequestParams {
|
||||
grant_type: string;
|
||||
code?: string;
|
||||
redirect_uri?: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
code_verifier?: string;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Token Response
|
||||
*/
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC UserInfo Response
|
||||
*/
|
||||
export interface UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
did?: string;
|
||||
vault_id?: string;
|
||||
updated_at?: number;
|
||||
claims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Header
|
||||
*/
|
||||
export interface JWTHeader {
|
||||
alg: string;
|
||||
typ: string;
|
||||
kid?: string;
|
||||
jku?: string;
|
||||
x5u?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Payload (ID Token Claims)
|
||||
*/
|
||||
export interface JWTPayload {
|
||||
iss: string;
|
||||
sub: string;
|
||||
aud: string | string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
nbf?: number;
|
||||
jti?: string;
|
||||
auth_time?: number;
|
||||
nonce?: string;
|
||||
acr?: string;
|
||||
amr?: string[];
|
||||
azp?: string;
|
||||
// Standard claims
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
// Custom claims
|
||||
did?: string;
|
||||
vault_id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Session Data
|
||||
*/
|
||||
export interface OIDCSession {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
expiresAt: number;
|
||||
userInfo?: UserInfo;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Error Response
|
||||
*/
|
||||
export interface OIDCError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants and Configuration
|
||||
// ============================================================================
|
||||
|
||||
const OIDC_DEFAULTS = {
|
||||
SCOPE: 'openid profile email',
|
||||
RESPONSE_TYPE: 'code',
|
||||
GRANT_TYPE: 'authorization_code',
|
||||
CODE_CHALLENGE_METHOD: 'S256',
|
||||
TOKEN_TYPE: 'Bearer',
|
||||
SESSION_STORAGE_KEY: 'oidc_session',
|
||||
STATE_STORAGE_KEY: 'oidc_state',
|
||||
NONCE_STORAGE_KEY: 'oidc_nonce',
|
||||
PKCE_STORAGE_KEY: 'oidc_pkce',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// CORS-Enabled Fetch Wrapper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* CORS-enabled fetch wrapper for OIDC endpoints
|
||||
*/
|
||||
export async function oidcFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
const config: RequestInit = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include',
|
||||
mode: 'cors',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('OIDC Fetch Error:', error);
|
||||
throw new Error(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PKCE Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for authorization code flow
|
||||
*/
|
||||
export function generatePKCE(): PKCEParams {
|
||||
const codeVerifier = generateRandomString(128);
|
||||
|
||||
// Synchronous fallback using base64url encoding
|
||||
const codeChallenge = btoa(codeVerifier)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
codeChallengeMethod: OIDC_DEFAULTS.CODE_CHALLENGE_METHOD,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store PKCE parameters in session storage
|
||||
*/
|
||||
export function storePKCE(pkce: PKCEParams): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY, JSON.stringify(pkce));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and clear PKCE parameters from session storage
|
||||
*/
|
||||
export function retrieveAndClearPKCE(): PKCEParams | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const stored = sessionStorage.getItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
sessionStorage.removeItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY);
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Discovery Document
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch OIDC discovery document from provider
|
||||
*/
|
||||
export async function fetchDiscoveryDocument(providerUrl: string): Promise<OIDCProviderConfig> {
|
||||
const discoveryUrl = `${providerUrl}/.well-known/openid-configuration`;
|
||||
|
||||
const response = await oidcFetch(discoveryUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=3600',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch discovery document: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const config = (await response.json()) as OIDCProviderConfig;
|
||||
|
||||
// Validate required endpoints
|
||||
if (!config.authorization_endpoint || !config.token_endpoint) {
|
||||
throw new Error('Invalid discovery document: missing required endpoints');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authorization URL Builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build authorization URL with PKCE support
|
||||
*/
|
||||
export async function buildAuthorizationUrl(
|
||||
config: OIDCClientConfig,
|
||||
providerConfig?: OIDCProviderConfig
|
||||
): Promise<{ url: string; state: string; nonce: string; pkce?: PKCEParams }> {
|
||||
// Fetch discovery document if not provided
|
||||
let discovery = providerConfig;
|
||||
if (!discovery) {
|
||||
discovery = await fetchDiscoveryDocument(config.providerUrl);
|
||||
}
|
||||
|
||||
// Generate state and nonce
|
||||
const state = generateRandomString(32);
|
||||
const nonce = generateRandomString(32);
|
||||
|
||||
// Generate PKCE parameters if enabled
|
||||
let pkce: PKCEParams | undefined;
|
||||
if (config.usePKCE) {
|
||||
pkce = generatePKCE();
|
||||
storePKCE(pkce);
|
||||
}
|
||||
|
||||
// Store state and nonce
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(OIDC_DEFAULTS.STATE_STORAGE_KEY, state);
|
||||
sessionStorage.setItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY, nonce);
|
||||
}
|
||||
|
||||
// Build authorization parameters
|
||||
const authParams: AuthorizationParams = {
|
||||
response_type: config.responseType,
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: config.scope,
|
||||
state,
|
||||
nonce,
|
||||
...config.additionalParams,
|
||||
};
|
||||
|
||||
// Add PKCE parameters if enabled
|
||||
if (pkce) {
|
||||
authParams.code_challenge = pkce.codeChallenge;
|
||||
authParams.code_challenge_method = pkce.codeChallengeMethod;
|
||||
}
|
||||
|
||||
// Build query string
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(authParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${discovery.authorization_endpoint}?${params.toString()}`;
|
||||
|
||||
return { url, state, nonce, pkce };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Exchange
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
config: OIDCClientConfig,
|
||||
providerConfig?: OIDCProviderConfig
|
||||
): Promise<TokenResponse> {
|
||||
// Fetch discovery document if not provided
|
||||
let discovery = providerConfig;
|
||||
if (!discovery) {
|
||||
discovery = await fetchDiscoveryDocument(config.providerUrl);
|
||||
}
|
||||
|
||||
// Retrieve PKCE parameters if used
|
||||
const pkce = config.usePKCE ? retrieveAndClearPKCE() : undefined;
|
||||
|
||||
// Build token request parameters
|
||||
const tokenParams: TokenRequestParams = {
|
||||
grant_type: config.grantType,
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
client_id: config.clientId,
|
||||
};
|
||||
|
||||
// Add client secret if provided
|
||||
if (config.clientSecret) {
|
||||
tokenParams.client_secret = config.clientSecret;
|
||||
}
|
||||
|
||||
// Add PKCE code verifier if used
|
||||
if (pkce) {
|
||||
tokenParams.code_verifier = pkce.codeVerifier;
|
||||
}
|
||||
|
||||
// Prepare form data (OIDC spec requires form encoding)
|
||||
const formData = new URLSearchParams();
|
||||
Object.entries(tokenParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await oidcFetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'token_error',
|
||||
error_description: 'Token exchange failed',
|
||||
}));
|
||||
throw new Error(`Token exchange failed: ${errorData.error_description || errorData.error}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
config: OIDCClientConfig,
|
||||
providerConfig?: OIDCProviderConfig
|
||||
): Promise<TokenResponse> {
|
||||
// Fetch discovery document if not provided
|
||||
let discovery = providerConfig;
|
||||
if (!discovery) {
|
||||
discovery = await fetchDiscoveryDocument(config.providerUrl);
|
||||
}
|
||||
|
||||
const tokenParams: TokenRequestParams = {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
};
|
||||
|
||||
if (config.clientSecret) {
|
||||
tokenParams.client_secret = config.clientSecret;
|
||||
}
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
Object.entries(tokenParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await oidcFetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'refresh_error',
|
||||
error_description: 'Token refresh failed',
|
||||
}));
|
||||
throw new Error(`Token refresh failed: ${errorData.error_description || errorData.error}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UserInfo Fetching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch user information using access token
|
||||
*/
|
||||
export async function fetchUserInfo(
|
||||
accessToken: string,
|
||||
config: OIDCClientConfig,
|
||||
providerConfig?: OIDCProviderConfig
|
||||
): Promise<UserInfo> {
|
||||
// Fetch discovery document if not provided
|
||||
let discovery = providerConfig;
|
||||
if (!discovery) {
|
||||
discovery = await fetchDiscoveryDocument(config.providerUrl);
|
||||
}
|
||||
|
||||
const response = await oidcFetch(discovery.userinfo_endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'userinfo_error',
|
||||
error_description: 'UserInfo request failed',
|
||||
}));
|
||||
throw new Error(`UserInfo request failed: ${errorData.error_description || errorData.error}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<UserInfo>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Decode JWT token without verification (for inspection only)
|
||||
* WARNING: This does not validate the token signature - use validateJWT for production
|
||||
*/
|
||||
export function decodeJWT(token: string): {
|
||||
header: JWTHeader;
|
||||
payload: JWTPayload;
|
||||
signature: string;
|
||||
} {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid JWT format');
|
||||
}
|
||||
|
||||
const [headerPart, payloadPart, signature] = parts;
|
||||
|
||||
if (!headerPart || !payloadPart || !signature) {
|
||||
throw new Error('Invalid JWT format: missing parts');
|
||||
}
|
||||
|
||||
try {
|
||||
const header = JSON.parse(atob(headerPart.replace(/-/g, '+').replace(/_/g, '/'))) as JWTHeader;
|
||||
const payload = JSON.parse(
|
||||
atob(payloadPart.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
) as JWTPayload;
|
||||
|
||||
return { header, payload, signature };
|
||||
} catch {
|
||||
throw new Error('Failed to decode JWT: Invalid encoding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JWT token (basic validation without signature verification)
|
||||
* For full validation, use a proper JWT library with JWKS support
|
||||
*/
|
||||
export function validateJWTClaims(
|
||||
token: string,
|
||||
expectedIssuer: string,
|
||||
expectedAudience: string,
|
||||
expectedNonce?: string
|
||||
): JWTPayload {
|
||||
const { payload } = decodeJWT(token);
|
||||
|
||||
// Validate issuer
|
||||
if (payload.iss !== expectedIssuer) {
|
||||
throw new Error(`Invalid issuer: expected ${expectedIssuer}, got ${payload.iss}`);
|
||||
}
|
||||
|
||||
// Validate audience
|
||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
if (!audiences.includes(expectedAudience)) {
|
||||
throw new Error(`Invalid audience: expected ${expectedAudience}, got ${audiences.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
throw new Error('Token has expired');
|
||||
}
|
||||
|
||||
// Validate not before
|
||||
if (payload.nbf && payload.nbf > now) {
|
||||
throw new Error('Token not yet valid');
|
||||
}
|
||||
|
||||
// Validate nonce if provided
|
||||
if (expectedNonce && payload.nonce !== expectedNonce) {
|
||||
throw new Error('Invalid nonce');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save OIDC session to storage
|
||||
*/
|
||||
export function saveSession(session: OIDCSession): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(OIDC_DEFAULTS.SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OIDC session from storage
|
||||
*/
|
||||
export function loadSession(): OIDCSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const stored = localStorage.getItem(OIDC_DEFAULTS.SESSION_STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
const session = JSON.parse(stored) as OIDCSession;
|
||||
|
||||
// Check if session is expired
|
||||
if (session.expiresAt && session.expiresAt < Date.now()) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear OIDC session from storage
|
||||
*/
|
||||
export function clearSession(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(OIDC_DEFAULTS.SESSION_STORAGE_KEY);
|
||||
sessionStorage.removeItem(OIDC_DEFAULTS.STATE_STORAGE_KEY);
|
||||
sessionStorage.removeItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY);
|
||||
sessionStorage.removeItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current session is valid and not expired
|
||||
*/
|
||||
export function isSessionValid(session?: OIDCSession): boolean {
|
||||
const currentSession = session || loadSession();
|
||||
if (!currentSession) return false;
|
||||
|
||||
return currentSession.expiresAt > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored state parameter for validation
|
||||
*/
|
||||
export function getStoredState(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const state = sessionStorage.getItem(OIDC_DEFAULTS.STATE_STORAGE_KEY);
|
||||
sessionStorage.removeItem(OIDC_DEFAULTS.STATE_STORAGE_KEY);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored nonce parameter for validation
|
||||
*/
|
||||
export function getStoredNonce(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const nonce = sessionStorage.getItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY);
|
||||
sessionStorage.removeItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// High-Level OIDC Client
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* High-level OIDC Client class
|
||||
*/
|
||||
export class OIDCClient {
|
||||
private config: OIDCClientConfig;
|
||||
private providerConfig?: OIDCProviderConfig;
|
||||
|
||||
constructor(config: OIDCClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the client by fetching provider configuration
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
this.providerConfig = await fetchDiscoveryDocument(this.config.providerUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start authorization flow
|
||||
*/
|
||||
async authorize(): Promise<string> {
|
||||
const { url } = await buildAuthorizationUrl(this.config, this.providerConfig);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authorization callback
|
||||
*/
|
||||
async handleCallback(callbackUrl: string): Promise<OIDCSession> {
|
||||
const url = new URL(callbackUrl);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
// Check for authorization errors
|
||||
if (error) {
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
throw new Error(
|
||||
`Authorization error: ${error}${errorDescription ? ` - ${errorDescription}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code) {
|
||||
throw new Error('Missing authorization code');
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
const storedState = getStoredState();
|
||||
if (!state || state !== storedState) {
|
||||
throw new Error('Invalid state parameter');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, this.config, this.providerConfig);
|
||||
|
||||
// Validate ID token if present
|
||||
let userInfo: UserInfo | undefined;
|
||||
if (tokens.id_token) {
|
||||
const storedNonce = getStoredNonce();
|
||||
const payload = validateJWTClaims(
|
||||
tokens.id_token,
|
||||
this.providerConfig?.issuer ?? '',
|
||||
this.config.clientId,
|
||||
storedNonce || undefined
|
||||
);
|
||||
|
||||
// Extract user info from ID token
|
||||
userInfo = {
|
||||
sub: payload.sub,
|
||||
name: payload.name,
|
||||
preferred_username: payload.preferred_username,
|
||||
email: payload.email,
|
||||
email_verified: payload.email_verified,
|
||||
did: payload.did,
|
||||
vault_id: payload.vault_id,
|
||||
};
|
||||
} else {
|
||||
// Fetch user info from UserInfo endpoint
|
||||
userInfo = await fetchUserInfo(tokens.access_token, this.config, this.providerConfig);
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session: OIDCSession = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
userInfo,
|
||||
tokenType: tokens.token_type,
|
||||
scope: tokens.scope,
|
||||
};
|
||||
|
||||
// Save session
|
||||
saveSession(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh current session
|
||||
*/
|
||||
async refreshSession(): Promise<OIDCSession | null> {
|
||||
const currentSession = loadSession();
|
||||
if (!currentSession?.refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await refreshAccessToken(
|
||||
currentSession.refreshToken,
|
||||
this.config,
|
||||
this.providerConfig
|
||||
);
|
||||
|
||||
const newSession: OIDCSession = {
|
||||
...currentSession,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || currentSession.refreshToken,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
};
|
||||
|
||||
saveSession(newSession);
|
||||
return newSession;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh session:', error);
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear session
|
||||
*/
|
||||
logout(): void {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*/
|
||||
getSession(): OIDCSession | null {
|
||||
return loadSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return isSessionValid();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if error is an OIDC error response
|
||||
*/
|
||||
export function isOIDCError(error: unknown): error is OIDCError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'error' in error &&
|
||||
typeof (error as Record<string, unknown>).error === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format OIDC error for display
|
||||
*/
|
||||
export function formatOIDCError(error: OIDCError): string {
|
||||
return `${error.error}${error.error_description ? `: ${error.error_description}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
/**
|
||||
* Self-Issued OpenID Provider (SIOP) v2 Utilities
|
||||
*
|
||||
* This module provides comprehensive SIOP v2 utilities for decentralized identity
|
||||
* authentication using DIDs (Decentralized Identifiers) and Verifiable Presentations.
|
||||
*
|
||||
* Features:
|
||||
* - SIOP request parsing and validation
|
||||
* - Self-issued ID token generation
|
||||
* - DID-based authentication
|
||||
* - Verifiable Presentation (VP) token utilities
|
||||
* - SIOP response builder
|
||||
* - Dynamic client registration
|
||||
* - DID document resolution
|
||||
*/
|
||||
|
||||
import { type JWTHeader, type JWTPayload, decodeJWT, generateRandomString } from './oidc';
|
||||
|
||||
// ============================================================================
|
||||
// Types and Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SIOP Request Parameters
|
||||
*/
|
||||
export interface SIOPRequest {
|
||||
response_type: 'id_token';
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
response_mode?: 'form_post' | 'fragment' | 'query';
|
||||
claims?: string | SIOPClaims;
|
||||
registration?: string | SIOPClientMetadata;
|
||||
request?: string; // JWT request object
|
||||
request_uri?: string;
|
||||
// SIOP specific parameters
|
||||
subject_syntax_types_supported?: string[];
|
||||
id_token_types_supported?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SIOP Claims Request
|
||||
*/
|
||||
export interface SIOPClaims {
|
||||
id_token?: {
|
||||
[key: string]: {
|
||||
essential?: boolean;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
} | null;
|
||||
};
|
||||
userinfo?: {
|
||||
[key: string]: {
|
||||
essential?: boolean;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SIOP Client Metadata for Dynamic Registration
|
||||
*/
|
||||
export interface SIOPClientMetadata {
|
||||
application_type?: string;
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
logo_uri?: string;
|
||||
contacts?: string[];
|
||||
tos_uri?: string;
|
||||
policy_uri?: string;
|
||||
redirect_uris?: string[];
|
||||
response_types?: string[];
|
||||
grant_types?: string[];
|
||||
subject_type?: 'public' | 'pairwise';
|
||||
id_token_signed_response_alg?: string;
|
||||
id_token_encrypted_response_alg?: string;
|
||||
id_token_encrypted_response_enc?: string;
|
||||
userinfo_signed_response_alg?: string;
|
||||
userinfo_encrypted_response_alg?: string;
|
||||
userinfo_encrypted_response_enc?: string;
|
||||
request_object_signing_alg?: string;
|
||||
request_object_encryption_alg?: string;
|
||||
request_object_encryption_enc?: string;
|
||||
// SIOP specific metadata
|
||||
subject_syntax_types_supported?: string[];
|
||||
id_token_types_supported?: string[];
|
||||
vp_formats?: VPFormats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiable Presentation Formats
|
||||
*/
|
||||
export interface VPFormats {
|
||||
jwt_vp?: {
|
||||
alg?: string[];
|
||||
};
|
||||
jwt_vc?: {
|
||||
alg?: string[];
|
||||
};
|
||||
ldp_vp?: {
|
||||
proof_type?: string[];
|
||||
};
|
||||
ldp_vc?: {
|
||||
proof_type?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SIOP Response
|
||||
*/
|
||||
export interface SIOPResponse {
|
||||
id_token: string;
|
||||
state?: string;
|
||||
vp_token?: string; // Verifiable Presentation token
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-Issued ID Token Payload
|
||||
*/
|
||||
export interface SelfIssuedIDToken extends JWTPayload {
|
||||
iss: string; // 'https://self-issued.me/v2' or DID
|
||||
sub: string; // DID or subject identifier
|
||||
aud: string; // Client ID
|
||||
sub_jwk?: JWK; // Subject's public key
|
||||
did?: string; // DID of the subject
|
||||
did_doc?: DIDDocument; // DID Document
|
||||
// VP token reference
|
||||
_vp_token?: {
|
||||
presentation_submission?: PresentationSubmission;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Web Key (JWK)
|
||||
*/
|
||||
export interface JWK {
|
||||
kty: string; // Key type
|
||||
use?: string; // Key use
|
||||
key_ops?: string[]; // Key operations
|
||||
alg?: string; // Algorithm
|
||||
kid?: string; // Key ID
|
||||
x5u?: string; // X.509 URL
|
||||
x5c?: string[]; // X.509 certificate chain
|
||||
x5t?: string; // X.509 thumbprint
|
||||
'x5t#S256'?: string; // X.509 thumbprint (SHA-256)
|
||||
// RSA keys
|
||||
n?: string; // Modulus
|
||||
e?: string; // Exponent
|
||||
d?: string; // Private exponent
|
||||
p?: string; // First prime factor
|
||||
q?: string; // Second prime factor
|
||||
dp?: string; // First factor CRT exponent
|
||||
dq?: string; // Second factor CRT exponent
|
||||
qi?: string; // First CRT coefficient
|
||||
// EC keys
|
||||
crv?: string; // Curve
|
||||
x?: string; // X coordinate
|
||||
y?: string; // Y coordinate
|
||||
// Symmetric keys
|
||||
k?: string; // Key value
|
||||
}
|
||||
|
||||
/**
|
||||
* DID Document (simplified)
|
||||
*/
|
||||
export interface DIDDocument {
|
||||
'@context'?: string | string[];
|
||||
id: string; // DID
|
||||
alsoKnownAs?: string[];
|
||||
controller?: string | string[];
|
||||
verificationMethod?: VerificationMethod[];
|
||||
authentication?: (string | VerificationMethod)[];
|
||||
assertionMethod?: (string | VerificationMethod)[];
|
||||
keyAgreement?: (string | VerificationMethod)[];
|
||||
capabilityInvocation?: (string | VerificationMethod)[];
|
||||
capabilityDelegation?: (string | VerificationMethod)[];
|
||||
service?: ServiceEndpoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification Method
|
||||
*/
|
||||
export interface VerificationMethod {
|
||||
id: string;
|
||||
type: string;
|
||||
controller: string;
|
||||
publicKeyJwk?: JWK;
|
||||
publicKeyMultibase?: string;
|
||||
publicKeyBase58?: string;
|
||||
blockchainAccountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Endpoint
|
||||
*/
|
||||
export interface ServiceEndpoint {
|
||||
id: string;
|
||||
type: string;
|
||||
serviceEndpoint: string | object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiable Presentation
|
||||
*/
|
||||
export interface VerifiablePresentation {
|
||||
'@context': string | string[];
|
||||
type: string | string[];
|
||||
verifiableCredential?: VerifiableCredential[];
|
||||
holder?: string; // DID of the holder
|
||||
proof?: Proof | Proof[];
|
||||
// Presentation submission
|
||||
presentation_submission?: PresentationSubmission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiable Credential
|
||||
*/
|
||||
export interface VerifiableCredential {
|
||||
'@context': string | string[];
|
||||
type: string | string[];
|
||||
issuer: string | { id: string; [key: string]: unknown };
|
||||
issuanceDate: string;
|
||||
expirationDate?: string;
|
||||
credentialSubject: CredentialSubject;
|
||||
proof?: Proof | Proof[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential Subject
|
||||
*/
|
||||
export interface CredentialSubject {
|
||||
id?: string; // DID of the subject
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cryptographic Proof
|
||||
*/
|
||||
export interface Proof {
|
||||
type: string;
|
||||
created: string;
|
||||
verificationMethod: string;
|
||||
proofPurpose: string;
|
||||
challenge?: string;
|
||||
domain?: string;
|
||||
proofValue?: string;
|
||||
jws?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation Submission
|
||||
*/
|
||||
export interface PresentationSubmission {
|
||||
id: string;
|
||||
definition_id: string;
|
||||
descriptor_map: InputDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Input Descriptor
|
||||
*/
|
||||
export interface InputDescriptor {
|
||||
id: string;
|
||||
format: string;
|
||||
path: string;
|
||||
path_nested?: InputDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* VP Token Payload
|
||||
*/
|
||||
export interface VPTokenPayload extends JWTPayload {
|
||||
iss: string; // DID of the presenter
|
||||
vp: VerifiablePresentation;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const SIOP_CONSTANTS = {
|
||||
SELF_ISSUED_ISSUER_V2: 'https://self-issued.me/v2',
|
||||
DEFAULT_RESPONSE_TYPE: 'id_token',
|
||||
DEFAULT_SCOPE: 'openid',
|
||||
SUBJECT_SYNTAX_TYPES: {
|
||||
JWK_THUMBPRINT: 'urn:ietf:params:oauth:jwk-thumbprint',
|
||||
DID: 'did',
|
||||
},
|
||||
ID_TOKEN_TYPES: {
|
||||
SUBJECT_SIGNED: 'subject-signed_id_token',
|
||||
ATTESTER_SIGNED: 'attester-signed_id_token',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// SIOP Request Parsing and Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse SIOP request from URL parameters
|
||||
*/
|
||||
export function parseSIOPRequest(searchParams: URLSearchParams): SIOPRequest {
|
||||
const request: SIOPRequest = {
|
||||
response_type: (searchParams.get('response_type') as 'id_token') || 'id_token',
|
||||
client_id: searchParams.get('client_id') || '',
|
||||
redirect_uri: searchParams.get('redirect_uri') || '',
|
||||
scope: searchParams.get('scope') || SIOP_CONSTANTS.DEFAULT_SCOPE,
|
||||
state: searchParams.get('state') || undefined,
|
||||
nonce: searchParams.get('nonce') || undefined,
|
||||
response_mode:
|
||||
(searchParams.get('response_mode') as 'form_post' | 'fragment' | 'query' | null) || undefined,
|
||||
request: searchParams.get('request') || undefined,
|
||||
request_uri: searchParams.get('request_uri') || undefined,
|
||||
};
|
||||
|
||||
// Parse claims parameter
|
||||
const claimsParam = searchParams.get('claims');
|
||||
if (claimsParam) {
|
||||
try {
|
||||
request.claims = JSON.parse(claimsParam) as SIOPClaims;
|
||||
} catch {
|
||||
request.claims = claimsParam;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse registration parameter
|
||||
const registrationParam = searchParams.get('registration');
|
||||
if (registrationParam) {
|
||||
try {
|
||||
request.registration = JSON.parse(registrationParam) as SIOPClientMetadata;
|
||||
} catch {
|
||||
request.registration = registrationParam;
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SIOP request parameters
|
||||
*/
|
||||
export function validateSIOPRequest(request: SIOPRequest): void {
|
||||
// Validate required parameters
|
||||
if (!request.client_id) {
|
||||
throw new Error('Missing required parameter: client_id');
|
||||
}
|
||||
|
||||
if (!request.redirect_uri) {
|
||||
throw new Error('Missing required parameter: redirect_uri');
|
||||
}
|
||||
|
||||
// Validate response_type
|
||||
if (request.response_type !== 'id_token') {
|
||||
throw new Error('Invalid response_type: only "id_token" is supported for SIOP');
|
||||
}
|
||||
|
||||
// Validate redirect URI format
|
||||
try {
|
||||
new URL(request.redirect_uri);
|
||||
} catch {
|
||||
throw new Error('Invalid redirect_uri format');
|
||||
}
|
||||
|
||||
// Validate scope contains 'openid'
|
||||
if (!request.scope.includes('openid')) {
|
||||
throw new Error('Scope must include "openid"');
|
||||
}
|
||||
|
||||
// If request object is present, it should be validated separately
|
||||
if (request.request && !request.request_uri) {
|
||||
try {
|
||||
decodeJWT(request.request);
|
||||
} catch {
|
||||
throw new Error('Invalid request object: not a valid JWT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve SIOP request object from JWT or URI
|
||||
*/
|
||||
export async function resolveSIOPRequestObject(request: SIOPRequest): Promise<SIOPRequest> {
|
||||
if (request.request_uri) {
|
||||
// Fetch request object from URI
|
||||
try {
|
||||
const response = await fetch(request.request_uri);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch request object: ${response.status}`);
|
||||
}
|
||||
const requestObject = await response.text();
|
||||
return parseJWTRequestObject(requestObject);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resolve request_uri: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
} else if (request.request) {
|
||||
// Parse inline request object
|
||||
return parseJWTRequestObject(request.request);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT request object
|
||||
*/
|
||||
function parseJWTRequestObject(jwt: string): SIOPRequest {
|
||||
const { payload } = decodeJWT(jwt);
|
||||
|
||||
return {
|
||||
response_type: (payload.response_type as 'id_token') || 'id_token',
|
||||
client_id: (payload.client_id as string) || '',
|
||||
redirect_uri: (payload.redirect_uri as string) || '',
|
||||
scope: (payload.scope as string) || SIOP_CONSTANTS.DEFAULT_SCOPE,
|
||||
state: (payload.state as string) || undefined,
|
||||
nonce: (payload.nonce as string) || undefined,
|
||||
response_mode:
|
||||
(payload.response_mode as 'form_post' | 'fragment' | 'query' | null) || undefined,
|
||||
claims: (payload.claims as SIOPClaims) || undefined,
|
||||
registration: (payload.registration as SIOPClientMetadata) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Self-Issued ID Token Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate self-issued ID token
|
||||
*/
|
||||
export async function generateSelfIssuedIDToken(
|
||||
request: SIOPRequest,
|
||||
did: string,
|
||||
privateKey: CryptoKey,
|
||||
additionalClaims?: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Create ID token payload
|
||||
const payload: SelfIssuedIDToken = {
|
||||
iss: SIOP_CONSTANTS.SELF_ISSUED_ISSUER_V2,
|
||||
sub: did,
|
||||
aud: request.client_id,
|
||||
exp: now + 3600, // 1 hour
|
||||
iat: now,
|
||||
nbf: now,
|
||||
jti: generateRandomString(16),
|
||||
did,
|
||||
...additionalClaims,
|
||||
};
|
||||
|
||||
// Add nonce if present
|
||||
if (request.nonce) {
|
||||
payload.nonce = request.nonce;
|
||||
}
|
||||
|
||||
// Create header
|
||||
const header: JWTHeader = {
|
||||
typ: 'JWT',
|
||||
alg: 'ES256', // Assuming ES256, adjust based on key type
|
||||
};
|
||||
|
||||
// Sign the token
|
||||
return signJWT(header, payload, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate subject JWK thumbprint
|
||||
*/
|
||||
export async function generateSubjectJWKThumbprint(publicKey: CryptoKey): Promise<string> {
|
||||
// Export public key as JWK
|
||||
const jwk = await crypto.subtle.exportKey('jwk', publicKey);
|
||||
|
||||
// Create canonical JWK for thumbprint
|
||||
const canonicalJWK = {
|
||||
kty: jwk.kty,
|
||||
...(jwk.crv && { crv: jwk.crv }),
|
||||
...(jwk.x && { x: jwk.x }),
|
||||
...(jwk.y && { y: jwk.y }),
|
||||
...(jwk.n && { n: jwk.n }),
|
||||
...(jwk.e && { e: jwk.e }),
|
||||
};
|
||||
|
||||
// Calculate SHA-256 thumbprint
|
||||
const canonicalString = JSON.stringify(canonicalJWK);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(canonicalString);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
|
||||
// Base64url encode
|
||||
return btoa(String.fromCharCode(...hashArray))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DID Document Resolution
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve DID document (simplified implementation)
|
||||
* In production, use a proper DID resolver library
|
||||
*/
|
||||
export async function resolveDIDDocument(did: string): Promise<DIDDocument> {
|
||||
// This is a simplified implementation
|
||||
// In practice, you would use a DID resolver that supports multiple DID methods
|
||||
|
||||
if (did.startsWith('did:web:')) {
|
||||
return resolveWebDID(did);
|
||||
}
|
||||
if (did.startsWith('did:key:')) {
|
||||
return resolveKeyDID(did);
|
||||
}
|
||||
if (did.startsWith('did:sonr:')) {
|
||||
return resolveSonrDID(did);
|
||||
}
|
||||
throw new Error(`Unsupported DID method: ${did}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve did:web DID
|
||||
*/
|
||||
async function resolveWebDID(did: string): Promise<DIDDocument> {
|
||||
// Extract domain from did:web
|
||||
const domain = did.replace('did:web:', '').replace(/:/g, '/');
|
||||
const url = `https://${domain}/.well-known/did.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve DID document: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<DIDDocument>;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resolve did:web: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve did:key DID (simplified)
|
||||
*/
|
||||
async function resolveKeyDID(did: string): Promise<DIDDocument> {
|
||||
// This is a very simplified implementation
|
||||
// In practice, you would properly decode the multibase key
|
||||
|
||||
const verificationMethod: VerificationMethod = {
|
||||
id: `${did}#keys-1`,
|
||||
type: 'Ed25519VerificationKey2020',
|
||||
controller: did,
|
||||
// In practice, decode the key from the DID
|
||||
publicKeyMultibase: did.split(':')[2],
|
||||
};
|
||||
|
||||
return {
|
||||
id: did,
|
||||
verificationMethod: [verificationMethod],
|
||||
authentication: [verificationMethod.id],
|
||||
assertionMethod: [verificationMethod.id],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve did:sonr DID from blockchain
|
||||
*/
|
||||
async function resolveSonrDID(did: string): Promise<DIDDocument> {
|
||||
// This would query the Sonr blockchain for the DID document
|
||||
// For now, return a mock structure
|
||||
|
||||
const identifier = did.split(':')[2];
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/did/resolve/${identifier}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve Sonr DID: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<DIDDocument>;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resolve did:sonr: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Verifiable Presentation Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create Verifiable Presentation token
|
||||
*/
|
||||
export async function createVPToken(
|
||||
presentation: VerifiablePresentation,
|
||||
holderDID: string,
|
||||
privateKey: CryptoKey,
|
||||
audience: string,
|
||||
nonce?: string
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: VPTokenPayload = {
|
||||
iss: holderDID,
|
||||
sub: holderDID,
|
||||
aud: audience,
|
||||
exp: now + 3600,
|
||||
iat: now,
|
||||
nbf: now,
|
||||
jti: generateRandomString(16),
|
||||
vp: presentation,
|
||||
};
|
||||
|
||||
if (nonce) {
|
||||
payload.nonce = nonce;
|
||||
}
|
||||
|
||||
const header: JWTHeader = {
|
||||
typ: 'JWT',
|
||||
alg: 'ES256',
|
||||
};
|
||||
|
||||
return signJWT(header, payload, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Verifiable Presentation
|
||||
*/
|
||||
export function validateVP(vp: VerifiablePresentation): void {
|
||||
// Basic validation
|
||||
if (!vp['@context'] || !vp.type) {
|
||||
throw new Error('VP must have @context and type');
|
||||
}
|
||||
|
||||
// Validate context
|
||||
const contexts = Array.isArray(vp['@context']) ? vp['@context'] : [vp['@context']];
|
||||
if (!contexts.includes('https://www.w3.org/2018/credentials/v1')) {
|
||||
throw new Error('VP must include credentials v1 context');
|
||||
}
|
||||
|
||||
// Validate type
|
||||
const types = Array.isArray(vp.type) ? vp.type : [vp.type];
|
||||
if (!types.includes('VerifiablePresentation')) {
|
||||
throw new Error('VP must include VerifiablePresentation type');
|
||||
}
|
||||
|
||||
// Validate credentials if present
|
||||
if (vp.verifiableCredential) {
|
||||
vp.verifiableCredential.forEach((vc, index) => {
|
||||
try {
|
||||
validateVC(vc);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid credential at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Verifiable Credential
|
||||
*/
|
||||
export function validateVC(vc: VerifiableCredential): void {
|
||||
if (!vc['@context'] || !vc.type || !vc.issuer || !vc.issuanceDate || !vc.credentialSubject) {
|
||||
throw new Error(
|
||||
'VC must have required fields: @context, type, issuer, issuanceDate, credentialSubject'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
if (vc.expirationDate) {
|
||||
const expirationDate = new Date(vc.expirationDate);
|
||||
if (expirationDate < new Date()) {
|
||||
throw new Error('Credential has expired');
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation would include signature verification
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SIOP Response Builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build SIOP response
|
||||
*/
|
||||
export async function buildSIOPResponse(
|
||||
request: SIOPRequest,
|
||||
did: string,
|
||||
privateKey: CryptoKey,
|
||||
vp?: VerifiablePresentation,
|
||||
additionalClaims?: Record<string, unknown>
|
||||
): Promise<SIOPResponse> {
|
||||
// Generate self-issued ID token
|
||||
const idToken = await generateSelfIssuedIDToken(request, did, privateKey, additionalClaims);
|
||||
|
||||
const response: SIOPResponse = {
|
||||
id_token: idToken,
|
||||
};
|
||||
|
||||
// Add state if present in request
|
||||
if (request.state) {
|
||||
response.state = request.state;
|
||||
}
|
||||
|
||||
// Add VP token if VP is provided
|
||||
if (vp) {
|
||||
validateVP(vp);
|
||||
response.vp_token = await createVPToken(vp, did, privateKey, request.client_id, request.nonce);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit SIOP response to client
|
||||
*/
|
||||
export async function submitSIOPResponse(
|
||||
response: SIOPResponse,
|
||||
redirectUri: string,
|
||||
responseMode: 'form_post' | 'fragment' | 'query' = 'fragment'
|
||||
): Promise<void> {
|
||||
if (responseMode === 'form_post') {
|
||||
// Submit via form post
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = redirectUri;
|
||||
|
||||
Object.entries(response).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} else {
|
||||
// Submit via redirect with parameters
|
||||
const url = new URL(redirectUri);
|
||||
const params = responseMode === 'fragment' ? new URLSearchParams() : url.searchParams;
|
||||
|
||||
Object.entries(response).forEach(([key, value]) => {
|
||||
params.append(key, value);
|
||||
});
|
||||
|
||||
const finalUrl =
|
||||
responseMode === 'fragment'
|
||||
? `${url.origin}${url.pathname}#${params.toString()}`
|
||||
: url.toString();
|
||||
|
||||
window.location.href = finalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dynamic Client Registration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate client metadata for dynamic registration
|
||||
*/
|
||||
export function validateClientMetadata(metadata: SIOPClientMetadata): void {
|
||||
// Validate required fields for SIOP
|
||||
if (!metadata.subject_type) {
|
||||
metadata.subject_type = 'public';
|
||||
}
|
||||
|
||||
// Validate redirect URIs
|
||||
if (metadata.redirect_uris) {
|
||||
metadata.redirect_uris.forEach((uri) => {
|
||||
try {
|
||||
new URL(uri);
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect URI: ${uri}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate response types
|
||||
if (metadata.response_types && !metadata.response_types.includes('id_token')) {
|
||||
throw new Error('SIOP clients must support "id_token" response type');
|
||||
}
|
||||
|
||||
// Validate subject syntax types
|
||||
if (metadata.subject_syntax_types_supported) {
|
||||
const validTypes = Object.values(SIOP_CONSTANTS.SUBJECT_SYNTAX_TYPES);
|
||||
const hasValidType = metadata.subject_syntax_types_supported.some((type) =>
|
||||
validTypes.includes(
|
||||
type as (typeof SIOP_CONSTANTS.SUBJECT_SYNTAX_TYPES)[keyof typeof SIOP_CONSTANTS.SUBJECT_SYNTAX_TYPES]
|
||||
)
|
||||
);
|
||||
|
||||
if (!hasValidType) {
|
||||
throw new Error('Client must support at least one valid subject syntax type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT Signing Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sign JWT with private key
|
||||
*/
|
||||
async function signJWT(
|
||||
header: JWTHeader,
|
||||
payload: Record<string, unknown>,
|
||||
privateKey: CryptoKey
|
||||
): Promise<string> {
|
||||
// Encode header and payload
|
||||
const encodedHeader = base64URLEncode(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const encodedPayload = base64URLEncode(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
|
||||
// Create signing input
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Sign
|
||||
const signature = await crypto.subtle.sign(
|
||||
'ECDSA',
|
||||
privateKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
// Encode signature
|
||||
const encodedSignature = base64URLEncode(new Uint8Array(signature));
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL encode utility
|
||||
*/
|
||||
function base64URLEncode(buffer: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...Array.from(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// High-Level SIOP Client
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SIOP Client for handling SIOP requests and responses
|
||||
*/
|
||||
export class SIOPClient {
|
||||
private did: string;
|
||||
private privateKey?: CryptoKey;
|
||||
private didDocument?: DIDDocument;
|
||||
|
||||
constructor(did: string, privateKey?: CryptoKey) {
|
||||
this.did = did;
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the client by resolving DID document
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
this.didDocument = await resolveDIDDocument(this.did);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming SIOP request
|
||||
*/
|
||||
async handleSIOPRequest(
|
||||
requestUrl: string,
|
||||
credentials?: VerifiableCredential[]
|
||||
): Promise<SIOPResponse> {
|
||||
const url = new URL(requestUrl);
|
||||
let request = parseSIOPRequest(url.searchParams);
|
||||
|
||||
// Resolve request object if present
|
||||
request = await resolveSIOPRequestObject(request);
|
||||
|
||||
// Validate request
|
||||
validateSIOPRequest(request);
|
||||
|
||||
if (!this.privateKey) {
|
||||
throw new Error('Private key required for signing');
|
||||
}
|
||||
|
||||
// Create VP if credentials are provided
|
||||
let vp: VerifiablePresentation | undefined;
|
||||
if (credentials && credentials.length > 0) {
|
||||
vp = {
|
||||
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
||||
type: ['VerifiablePresentation'],
|
||||
verifiableCredential: credentials,
|
||||
holder: this.did,
|
||||
};
|
||||
}
|
||||
|
||||
// Build and return response
|
||||
return buildSIOPResponse(request, this.did, this.privateKey, vp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit SIOP response
|
||||
*/
|
||||
async submitResponse(
|
||||
response: SIOPResponse,
|
||||
redirectUri: string,
|
||||
responseMode?: 'form_post' | 'fragment' | 'query'
|
||||
): Promise<void> {
|
||||
await submitSIOPResponse(response, redirectUri, responseMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DID document
|
||||
*/
|
||||
getDIDDocument(): DIDDocument | undefined {
|
||||
return this.didDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set private key
|
||||
*/
|
||||
setPrivateKey(privateKey: CryptoKey): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a request is a SIOP request
|
||||
*/
|
||||
export function isSIOPRequest(searchParams: URLSearchParams): boolean {
|
||||
const responseType = searchParams.get('response_type');
|
||||
const scope = searchParams.get('scope');
|
||||
|
||||
return responseType === 'id_token' && scope?.includes('openid') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract DID from self-issued ID token
|
||||
*/
|
||||
export function extractDIDFromSelfIssuedToken(idToken: string): string | null {
|
||||
try {
|
||||
const { payload } = decodeJWT(idToken);
|
||||
return (payload as SelfIssuedIDToken).did || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate self-issued ID token structure
|
||||
*/
|
||||
export function validateSelfIssuedIDToken(
|
||||
idToken: string,
|
||||
expectedAudience: string
|
||||
): SelfIssuedIDToken {
|
||||
const { payload } = decodeJWT(idToken);
|
||||
const token = payload as SelfIssuedIDToken;
|
||||
|
||||
// Validate issuer
|
||||
if (token.iss !== SIOP_CONSTANTS.SELF_ISSUED_ISSUER_V2 && !token.did?.startsWith('did:')) {
|
||||
throw new Error('Invalid issuer for self-issued ID token');
|
||||
}
|
||||
|
||||
// Validate audience
|
||||
if (token.aud !== expectedAudience) {
|
||||
throw new Error('Invalid audience');
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (token.exp && token.exp < now) {
|
||||
throw new Error('Token has expired');
|
||||
}
|
||||
|
||||
// Validate not before
|
||||
if (token.nbf && token.nbf > now) {
|
||||
throw new Error('Token not yet valid');
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* UCAN delegation utilities for OAuth2 integration
|
||||
*/
|
||||
|
||||
import { JWK, SignJWT, importJWK } from 'jose';
|
||||
|
||||
/**
|
||||
* UCAN token structure
|
||||
*/
|
||||
export interface UCANToken {
|
||||
iss: string; // Issuer DID
|
||||
aud: string; // Audience DID
|
||||
exp: number; // Expiration timestamp
|
||||
nbf?: number; // Not before timestamp
|
||||
att: UCANCapability[]; // Attenuations/capabilities
|
||||
prf?: string[]; // Proof chain
|
||||
fct?: any[]; // Facts
|
||||
}
|
||||
|
||||
/**
|
||||
* UCAN capability/attenuation
|
||||
*/
|
||||
export interface UCANCapability {
|
||||
with: string; // Resource URI
|
||||
can: string; // Action/capability
|
||||
nb?: Record<string, any>; // Caveats/constraints
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth scope to UCAN capability mapping
|
||||
*/
|
||||
export interface ScopeMapping {
|
||||
scope: string;
|
||||
capabilities: UCANCapability[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scope mappings
|
||||
*/
|
||||
export const DEFAULT_SCOPE_MAPPINGS: ScopeMapping[] = [
|
||||
{
|
||||
scope: 'openid',
|
||||
capabilities: [{ with: 'did:*', can: 'identify' }],
|
||||
},
|
||||
{
|
||||
scope: 'profile',
|
||||
capabilities: [
|
||||
{ with: 'profile:*', can: 'read' },
|
||||
{ with: 'did:*', can: 'resolve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
scope: 'vault:read',
|
||||
capabilities: [
|
||||
{ with: 'vault:*', can: 'read' },
|
||||
{ with: 'vault:*', can: 'list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
scope: 'vault:write',
|
||||
capabilities: [
|
||||
{ with: 'vault:*', can: 'write' },
|
||||
{ with: 'vault:*', can: 'create' },
|
||||
{ with: 'vault:*', can: 'update' },
|
||||
{ with: 'vault:*', can: 'delete' },
|
||||
],
|
||||
},
|
||||
{
|
||||
scope: 'vault:sign',
|
||||
capabilities: [
|
||||
{ with: 'vault:*', can: 'sign' },
|
||||
{ with: 'tx:*', can: 'sign' },
|
||||
],
|
||||
},
|
||||
{
|
||||
scope: 'service:manage',
|
||||
capabilities: [
|
||||
{ with: 'service:*', can: 'create' },
|
||||
{ with: 'service:*', can: 'update' },
|
||||
{ with: 'service:*', can: 'delete' },
|
||||
{ with: 'service:*', can: 'list' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Map OAuth scopes to UCAN capabilities
|
||||
*/
|
||||
export function scopesToCapabilities(scopes: string[]): UCANCapability[] {
|
||||
const capabilities: UCANCapability[] = [];
|
||||
|
||||
for (const scope of scopes) {
|
||||
const mapping = DEFAULT_SCOPE_MAPPINGS.find((m) => m.scope === scope);
|
||||
if (mapping) {
|
||||
capabilities.push(...mapping.capabilities);
|
||||
} else {
|
||||
// Handle custom scopes
|
||||
capabilities.push({
|
||||
with: `custom:${scope}`,
|
||||
can: '*',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate capabilities
|
||||
const unique = new Map<string, UCANCapability>();
|
||||
for (const cap of capabilities) {
|
||||
const key = `${cap.with}:${cap.can}`;
|
||||
if (!unique.has(key)) {
|
||||
unique.set(key, cap);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse UCAN token from JWT string
|
||||
*/
|
||||
export async function parseUCAN(token: string): Promise<UCANToken> {
|
||||
// Decode JWT without verification (client-side)
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid UCAN token format');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
return {
|
||||
iss: payload.iss,
|
||||
aud: payload.aud,
|
||||
exp: payload.exp,
|
||||
nbf: payload.nbf,
|
||||
att: payload.att || [],
|
||||
prf: payload.prf || [],
|
||||
fct: payload.fct || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate UCAN token capabilities
|
||||
*/
|
||||
export function validateCapabilities(
|
||||
token: UCANToken,
|
||||
requiredCapabilities: UCANCapability[]
|
||||
): boolean {
|
||||
for (const required of requiredCapabilities) {
|
||||
const hasCapability = token.att.some((cap) => {
|
||||
// Check if the capability matches
|
||||
if (!matchResource(cap.with, required.with)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!matchAction(cap.can, required.can)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check caveats if present
|
||||
if (required.nb) {
|
||||
if (!cap.nb) return false;
|
||||
|
||||
for (const [key, value] of Object.entries(required.nb)) {
|
||||
if (cap.nb[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!hasCapability) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match resource patterns
|
||||
*/
|
||||
function matchResource(pattern: string, resource: string): boolean {
|
||||
// Handle wildcards
|
||||
if (pattern === '*' || resource === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle prefix wildcards
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return resource.startsWith(prefix);
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return pattern === resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match action patterns
|
||||
*/
|
||||
function matchAction(pattern: string, action: string): boolean {
|
||||
// Handle wildcards
|
||||
if (pattern === '*' || action === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return pattern === action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a delegation chain
|
||||
*/
|
||||
export async function createDelegation(
|
||||
issuerDID: string,
|
||||
audienceDID: string,
|
||||
capabilities: UCANCapability[],
|
||||
expiresIn = 3600,
|
||||
proofs?: string[]
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload = {
|
||||
iss: issuerDID,
|
||||
aud: audienceDID,
|
||||
exp: now + expiresIn,
|
||||
nbf: now,
|
||||
att: capabilities,
|
||||
prf: proofs || [],
|
||||
};
|
||||
|
||||
// In production, this would be signed with the issuer's private key
|
||||
// For client-side, we'll create an unsigned token
|
||||
const header = { alg: 'none', typ: 'JWT', ucv: '0.9.0' };
|
||||
|
||||
const encodedHeader = btoa(JSON.stringify(header))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
const encodedPayload = btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
return `${encodedHeader}.${encodedPayload}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify UCAN delegation chain
|
||||
*/
|
||||
export async function verifyDelegationChain(token: string, rootDID: string): Promise<boolean> {
|
||||
try {
|
||||
const ucan = await parseUCAN(token);
|
||||
|
||||
// Check expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (ucan.exp && ucan.exp < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check not before
|
||||
if (ucan.nbf && ucan.nbf > now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is the root delegation
|
||||
if (ucan.iss === rootDID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verify proof chain
|
||||
if (!ucan.prf || ucan.prf.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recursively verify each proof
|
||||
for (const proof of ucan.prf) {
|
||||
const isValid = await verifyDelegationChain(proof, rootDID);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify that the proof delegates to this token's issuer
|
||||
const proofUCAN = await parseUCAN(proof);
|
||||
if (proofUCAN.aud !== ucan.iss) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify capabilities are attenuated
|
||||
if (!isAttenuated(proofUCAN.att, ucan.att)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if capabilities are properly attenuated
|
||||
*/
|
||||
function isAttenuated(parentCaps: UCANCapability[], childCaps: UCANCapability[]): boolean {
|
||||
// Each child capability must be allowed by at least one parent capability
|
||||
for (const childCap of childCaps) {
|
||||
const isAllowed = parentCaps.some((parentCap) => {
|
||||
// Check resource is same or more specific
|
||||
if (!matchResource(parentCap.with, childCap.with)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check action is same or more specific
|
||||
if (!matchAction(parentCap.can, childCap.can)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check caveats are same or more restrictive
|
||||
if (childCap.nb) {
|
||||
if (!parentCap.nb) {
|
||||
// Child has caveats but parent doesn't - this is more restrictive, so OK
|
||||
return true;
|
||||
}
|
||||
|
||||
// All parent caveats must be present in child
|
||||
for (const [key, value] of Object.entries(parentCap.nb)) {
|
||||
if (childCap.nb[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable capability description
|
||||
*/
|
||||
export function describeCapability(cap: UCANCapability): string {
|
||||
const resource = cap.with.replace(':', ' ').replace('*', 'all');
|
||||
const action = cap.can.replace('*', 'all actions');
|
||||
|
||||
let description = `Can ${action} on ${resource}`;
|
||||
|
||||
if (cap.nb && Object.keys(cap.nb).length > 0) {
|
||||
const caveats = Object.entries(cap.nb)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(', ');
|
||||
description += ` (with constraints: ${caveats})`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export utilities
|
||||
*/
|
||||
export const UCANUtils = {
|
||||
scopesToCapabilities,
|
||||
parseUCAN,
|
||||
validateCapabilities,
|
||||
createDelegation,
|
||||
verifyDelegationChain,
|
||||
describeCapability,
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* OAuth scope utilities for UCAN integration
|
||||
*/
|
||||
|
||||
import type { UCANCapability } from './delegation';
|
||||
|
||||
/**
|
||||
* OAuth scope definition
|
||||
*/
|
||||
export interface OAuthScope {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
required?: boolean;
|
||||
capabilities: UCANCapability[];
|
||||
children?: string[]; // Hierarchical scopes
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope registry
|
||||
*/
|
||||
export class ScopeRegistry {
|
||||
private scopes: Map<string, OAuthScope> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.registerDefaultScopes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default OAuth scopes
|
||||
*/
|
||||
private registerDefaultScopes() {
|
||||
// OpenID Connect scopes
|
||||
this.register({
|
||||
name: 'openid',
|
||||
title: 'OpenID',
|
||||
description: 'Authenticate using your Sonr identity',
|
||||
required: true,
|
||||
capabilities: [{ with: 'did:*', can: 'identify' }],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'profile',
|
||||
title: 'Profile',
|
||||
description: 'Access your basic profile information',
|
||||
capabilities: [
|
||||
{ with: 'profile:*', can: 'read' },
|
||||
{ with: 'did:*', can: 'resolve' },
|
||||
],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'email',
|
||||
title: 'Email',
|
||||
description: 'Access your email address',
|
||||
capabilities: [{ with: 'profile:email', can: 'read' }],
|
||||
});
|
||||
|
||||
// Vault scopes
|
||||
this.register({
|
||||
name: 'vault:read',
|
||||
title: 'Read Vault',
|
||||
description: 'Read data from your encrypted vault',
|
||||
capabilities: [
|
||||
{ with: 'vault:*', can: 'read' },
|
||||
{ with: 'vault:*', can: 'list' },
|
||||
],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'vault:write',
|
||||
title: 'Write Vault',
|
||||
description: 'Create and modify data in your vault',
|
||||
capabilities: [
|
||||
{ with: 'vault:*', can: 'write' },
|
||||
{ with: 'vault:*', can: 'create' },
|
||||
{ with: 'vault:*', can: 'update' },
|
||||
],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'vault:delete',
|
||||
title: 'Delete Vault Data',
|
||||
description: 'Delete data from your vault',
|
||||
capabilities: [{ with: 'vault:*', can: 'delete' }],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'vault:sign',
|
||||
title: 'Sign with Vault',
|
||||
description: 'Sign messages and transactions using vault keys',
|
||||
capabilities: [
|
||||
{ with: 'vault:*', can: 'sign' },
|
||||
{ with: 'tx:*', can: 'sign' },
|
||||
],
|
||||
});
|
||||
|
||||
// Hierarchical vault scope
|
||||
this.register({
|
||||
name: 'vault:admin',
|
||||
title: 'Vault Admin',
|
||||
description: 'Full administrative access to your vault',
|
||||
capabilities: [{ with: 'vault:*', can: '*' }],
|
||||
children: ['vault:read', 'vault:write', 'vault:delete', 'vault:sign'],
|
||||
});
|
||||
|
||||
// Service management scopes
|
||||
this.register({
|
||||
name: 'service:read',
|
||||
title: 'Read Services',
|
||||
description: 'List and view your registered services',
|
||||
capabilities: [
|
||||
{ with: 'service:*', can: 'read' },
|
||||
{ with: 'service:*', can: 'list' },
|
||||
],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'service:write',
|
||||
title: 'Manage Services',
|
||||
description: 'Create and update service registrations',
|
||||
capabilities: [
|
||||
{ with: 'service:*', can: 'create' },
|
||||
{ with: 'service:*', can: 'update' },
|
||||
],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'service:delete',
|
||||
title: 'Delete Services',
|
||||
description: 'Remove service registrations',
|
||||
capabilities: [{ with: 'service:*', can: 'delete' }],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'service:manage',
|
||||
title: 'Service Admin',
|
||||
description: 'Full control over service registrations',
|
||||
capabilities: [{ with: 'service:*', can: '*' }],
|
||||
children: ['service:read', 'service:write', 'service:delete'],
|
||||
});
|
||||
|
||||
// DWN (Decentralized Web Node) scopes
|
||||
this.register({
|
||||
name: 'dwn:read',
|
||||
title: 'Read DWN',
|
||||
description: 'Read data from your Decentralized Web Node',
|
||||
capabilities: [
|
||||
{ with: 'dwn:*', can: 'read' },
|
||||
{ with: 'dwn:*', can: 'query' },
|
||||
],
|
||||
});
|
||||
|
||||
this.register({
|
||||
name: 'dwn:write',
|
||||
title: 'Write DWN',
|
||||
description: 'Store data in your Decentralized Web Node',
|
||||
capabilities: [
|
||||
{ with: 'dwn:*', can: 'write' },
|
||||
{ with: 'dwn:*', can: 'create' },
|
||||
],
|
||||
});
|
||||
|
||||
// Offline access
|
||||
this.register({
|
||||
name: 'offline_access',
|
||||
title: 'Offline Access',
|
||||
description: "Access your account when you're not present",
|
||||
capabilities: [{ with: 'token:*', can: 'refresh' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new scope
|
||||
*/
|
||||
register(scope: OAuthScope): void {
|
||||
this.scopes.set(scope.name, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a scope by name
|
||||
*/
|
||||
get(name: string): OAuthScope | undefined {
|
||||
return this.scopes.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered scopes
|
||||
*/
|
||||
getAll(): OAuthScope[] {
|
||||
return Array.from(this.scopes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scope exists
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.scopes.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate requested scopes
|
||||
*/
|
||||
validate(requested: string[]): { valid: string[]; invalid: string[] } {
|
||||
const valid: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
for (const scope of requested) {
|
||||
if (this.has(scope)) {
|
||||
valid.push(scope);
|
||||
} else {
|
||||
invalid.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand hierarchical scopes
|
||||
*/
|
||||
expand(scopes: string[]): string[] {
|
||||
const expanded = new Set<string>();
|
||||
|
||||
for (const scope of scopes) {
|
||||
expanded.add(scope);
|
||||
|
||||
const definition = this.get(scope);
|
||||
if (definition?.children) {
|
||||
for (const child of definition.children) {
|
||||
expanded.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capabilities for scopes
|
||||
*/
|
||||
getCapabilities(scopes: string[]): UCANCapability[] {
|
||||
const capabilities: UCANCapability[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Expand hierarchical scopes
|
||||
const expanded = this.expand(scopes);
|
||||
|
||||
for (const scope of expanded) {
|
||||
const definition = this.get(scope);
|
||||
if (definition) {
|
||||
for (const cap of definition.capabilities) {
|
||||
const key = `${cap.with}:${cap.can}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
capabilities.push(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required scopes from a list
|
||||
*/
|
||||
getRequired(scopes: string[]): string[] {
|
||||
return scopes.filter((scope) => {
|
||||
const definition = this.get(scope);
|
||||
return definition?.required === true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group scopes by category
|
||||
*/
|
||||
groupByCategory(scopes: string[]): Map<string, OAuthScope[]> {
|
||||
const groups = new Map<string, OAuthScope[]>();
|
||||
|
||||
for (const scope of scopes) {
|
||||
const definition = this.get(scope);
|
||||
if (definition) {
|
||||
const category = scope.split(':')[0] || 'general';
|
||||
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, []);
|
||||
}
|
||||
|
||||
groups.get(category)!.push(definition);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scope registry instance
|
||||
*/
|
||||
export const scopeRegistry = new ScopeRegistry();
|
||||
|
||||
/**
|
||||
* Scope validation utilities
|
||||
*/
|
||||
export const ScopeValidator = {
|
||||
/**
|
||||
* Check if scopes are valid
|
||||
*/
|
||||
isValid(scopes: string[]): boolean {
|
||||
const { invalid } = scopeRegistry.validate(scopes);
|
||||
return invalid.length === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if scope is hierarchical
|
||||
*/
|
||||
isHierarchical(scope: string): boolean {
|
||||
const definition = scopeRegistry.get(scope);
|
||||
return !!(definition?.children && definition.children.length > 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if one scope includes another
|
||||
*/
|
||||
includes(parent: string, child: string): boolean {
|
||||
const definition = scopeRegistry.get(parent);
|
||||
if (!definition?.children) return false;
|
||||
|
||||
return definition.children.includes(child);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get minimal scope set (remove redundant child scopes)
|
||||
*/
|
||||
minimize(scopes: string[]): string[] {
|
||||
const minimal = new Set<string>(scopes);
|
||||
|
||||
for (const scope of scopes) {
|
||||
const definition = scopeRegistry.get(scope);
|
||||
if (definition?.children) {
|
||||
// Remove children if parent is present
|
||||
for (const child of definition.children) {
|
||||
minimal.delete(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(minimal);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if scopes grant a specific capability
|
||||
*/
|
||||
hasCapability(scopes: string[], resource: string, action: string): boolean {
|
||||
const capabilities = scopeRegistry.getCapabilities(scopes);
|
||||
|
||||
return capabilities.some((cap) => {
|
||||
const resourceMatch =
|
||||
cap.with === resource ||
|
||||
cap.with === '*' ||
|
||||
(cap.with.endsWith('*') && resource.startsWith(cap.with.slice(0, -1)));
|
||||
|
||||
const actionMatch = cap.can === action || cap.can === '*';
|
||||
|
||||
return resourceMatch && actionMatch;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Scope formatting utilities
|
||||
*/
|
||||
export const ScopeFormatter = {
|
||||
/**
|
||||
* Format scope for display
|
||||
*/
|
||||
format(scope: string): string {
|
||||
const definition = scopeRegistry.get(scope);
|
||||
return definition?.title || scope;
|
||||
},
|
||||
|
||||
/**
|
||||
* Format scope list
|
||||
*/
|
||||
formatList(scopes: string[]): string {
|
||||
return scopes.map((s) => this.format(s)).join(', ');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get scope description
|
||||
*/
|
||||
describe(scope: string): string {
|
||||
const definition = scopeRegistry.get(scope);
|
||||
return definition?.description || `Access to ${scope}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get scope icon
|
||||
*/
|
||||
getIcon(scope: string): string | undefined {
|
||||
const definition = scopeRegistry.get(scope);
|
||||
return definition?.icon;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Export utilities
|
||||
*/
|
||||
export default {
|
||||
registry: scopeRegistry,
|
||||
validator: ScopeValidator,
|
||||
formatter: ScopeFormatter,
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
// Include @sonr.io/ui components
|
||||
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
prefix: '',
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
chart: {
|
||||
1: 'hsl(var(--chart-1))',
|
||||
2: 'hsl(var(--chart-2))',
|
||||
3: 'hsl(var(--chart-3))',
|
||||
4: 'hsl(var(--chart-4))',
|
||||
5: 'hsl(var(--chart-5))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'fade-out': {
|
||||
'0%': { opacity: '1' },
|
||||
'100%': { opacity: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'fade-in': 'fade-in 0.5s ease-in-out',
|
||||
'fade-out': 'fade-out 0.5s ease-in-out',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
||||
mono: ['var(--font-mono)', 'Consolas', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
main = ".open-next/worker.js"
|
||||
name = "highway-auth"
|
||||
compatibility_date = "2025-03-25"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[dev]
|
||||
port = 3100
|
||||
|
||||
[assets]
|
||||
directory = ".open-next/assets"
|
||||
binding = "ASSETS"
|
||||
|
||||
# Development environment (default)
|
||||
[env.development]
|
||||
# Add any development-specific environment variables here
|
||||
|
||||
# Production environment
|
||||
[env.production]
|
||||
# Add any production-specific environment variables here
|
||||
|
||||
# Build configuration is handled separately by opennextjs-cloudflare
|
||||
[build]
|
||||
command = "npx opennextjs-cloudflare build"
|
||||
cwd = "."
|
||||
|
||||
# Analytics and observability
|
||||
[observability]
|
||||
enabled = true
|
||||
@@ -0,0 +1,16 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_customize"
|
||||
tag_format = "web-dash/v$version"
|
||||
ignored_tag_formats = ["*/v${version}", "v${version}"]
|
||||
version_scheme = "semver"
|
||||
version_provider = "npm"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
pre_bump_hooks = ["bash ../../scripts/hook-bump-pre.sh"]
|
||||
post_bump_hooks = ["pnpm --filter '@sonr.io/dash' deploy:cloudflare"]
|
||||
|
||||
[tool.commitizen.customize]
|
||||
bump_pattern = "^(feat|fix|refactor|perf|BREAKING CHANGE)"
|
||||
bump_map = { "BREAKING CHANGE" = "MAJOR", "feat" = "MINOR", "fix" = "PATCH", "refactor" = "PATCH", "perf" = "PATCH" }
|
||||
default_bump = "PATCH"
|
||||
changelog_pattern = "^(feat|fix|refactor|docs|build)\\(dash\\)(!)?:"
|
||||
@@ -0,0 +1,61 @@
|
||||
# Chain Configuration
|
||||
NEXT_PUBLIC_CHAIN_ID=sonrtest_1-1
|
||||
NEXT_PUBLIC_CHAIN_ENDPOINT=http://localhost:26657
|
||||
NEXT_PUBLIC_RPC_ENDPOINT=http://localhost:1317
|
||||
NEXT_PUBLIC_GRPC_ENDPOINT=http://localhost:9090
|
||||
NEXT_PUBLIC_WS_ENDPOINT=ws://localhost:26657/websocket
|
||||
|
||||
# Authentication
|
||||
NEXT_PUBLIC_AUTH_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_AUTH_DOMAIN=localhost
|
||||
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_VERSION=v1
|
||||
|
||||
# Service Configuration
|
||||
NEXT_PUBLIC_SERVICE_DOMAIN=api.example.com
|
||||
NEXT_PUBLIC_SERVICE_NAME=Sonr Dashboard
|
||||
|
||||
# Feature Flags
|
||||
NEXT_PUBLIC_ENABLE_ANALYTICS=true
|
||||
NEXT_PUBLIC_ENABLE_REALTIME=true
|
||||
NEXT_PUBLIC_ENABLE_MOCK_DATA=false
|
||||
|
||||
# Development Settings
|
||||
NEXT_PUBLIC_DEBUG=false
|
||||
NEXT_PUBLIC_LOG_LEVEL=info
|
||||
|
||||
# Domain Verification
|
||||
NEXT_PUBLIC_DNS_CHECK_INTERVAL=5000
|
||||
NEXT_PUBLIC_DNS_MAX_ATTEMPTS=60
|
||||
NEXT_PUBLIC_VERIFICATION_PREFIX=sonr-verification
|
||||
|
||||
# Rate Limiting
|
||||
NEXT_PUBLIC_MAX_REQUESTS_PER_MINUTE=100
|
||||
NEXT_PUBLIC_MAX_REQUESTS_PER_HOUR=1000
|
||||
|
||||
# Monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_GA_TRACKING_ID=
|
||||
|
||||
# External Services
|
||||
NEXT_PUBLIC_IPFS_GATEWAY=https://ipfs.io/ipfs/
|
||||
NEXT_PUBLIC_IPFS_API=http://localhost:5001
|
||||
|
||||
# Testnet Configuration
|
||||
NEXT_PUBLIC_FAUCET_URL=https://faucet.sonr.io
|
||||
NEXT_PUBLIC_EXPLORER_URL=https://explorer.sonr.io
|
||||
|
||||
# WebAuthn Configuration
|
||||
NEXT_PUBLIC_WEBAUTHN_RP_NAME=Sonr Dashboard
|
||||
NEXT_PUBLIC_WEBAUTHN_RP_ID=localhost
|
||||
NEXT_PUBLIC_WEBAUTHN_ORIGIN=http://localhost:3000
|
||||
|
||||
# Cache Settings
|
||||
NEXT_PUBLIC_CACHE_TTL=300000
|
||||
NEXT_PUBLIC_STALE_TIME=60000
|
||||
|
||||
# Pagination
|
||||
NEXT_PUBLIC_DEFAULT_PAGE_SIZE=20
|
||||
NEXT_PUBLIC_MAX_PAGE_SIZE=100
|
||||
@@ -0,0 +1,24 @@
|
||||
# Staging Environment Configuration for Dashboard App
|
||||
NEXT_PUBLIC_ENVIRONMENT=staging
|
||||
NEXT_PUBLIC_API_URL=https://highway-api-staging.workers.dev
|
||||
NEXT_PUBLIC_APP_URL=https://staging.app.sonr.id
|
||||
NEXT_PUBLIC_AUTH_URL=https://staging.auth.sonr.id
|
||||
NEXT_PUBLIC_MAIN_SITE_URL=https://staging.sonr.id
|
||||
|
||||
# WebAuthn Configuration
|
||||
NEXT_PUBLIC_RP_ID=staging.highway.sonr.id
|
||||
NEXT_PUBLIC_RP_NAME="Highway Staging"
|
||||
|
||||
# Feature Flags
|
||||
NEXT_PUBLIC_ENABLE_DEBUG=true
|
||||
NEXT_PUBLIC_ENABLE_DEV_TOOLS=true
|
||||
NEXT_PUBLIC_ENABLE_ADMIN_PANEL=true
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_ANALYTICS_ID=staging-dashboard-analytics-id
|
||||
|
||||
# Error Tracking
|
||||
NEXT_PUBLIC_SENTRY_DSN=staging-dashboard-sentry-dsn
|
||||
|
||||
# Logging
|
||||
NEXT_PUBLIC_LOG_LEVEL=info
|
||||
@@ -0,0 +1,71 @@
|
||||
# Use Node.js Alpine as base image
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy root package files and workspace configuration
|
||||
COPY ../../package.json ../../pnpm-lock.yaml ../../pnpm-workspace.yaml ./
|
||||
COPY ../../turbo.json ./
|
||||
|
||||
# Copy package.json files for all workspace dependencies
|
||||
COPY ../../packages/es/package.json ./packages/es/
|
||||
COPY ../../packages/sdk/package.json ./packages/sdk/
|
||||
COPY ../../packages/com/package.json ./packages/com/
|
||||
COPY ../../packages/ui/package.json ./packages/ui/
|
||||
COPY ../../packages/pkl/package.json ./packages/pkl/
|
||||
COPY ../../packages/cli/package.json ./packages/cli/
|
||||
COPY ./package.json ./web/dash/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy installed dependencies
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/.pnpm ./.pnpm
|
||||
|
||||
# Copy source code
|
||||
COPY ../.. .
|
||||
|
||||
# Build the application
|
||||
WORKDIR /app/web/dash
|
||||
RUN pnpm build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/web/dash/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/dash/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/dash/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,113 @@
|
||||
# Dashboard App
|
||||
|
||||
This is the user dashboard for Highway WebAuthn authentication gateway. It provides a comprehensive interface for user management, console interactions, and system monitoring, integrating with the Blockchain x/svc module.
|
||||
|
||||
## Features
|
||||
|
||||
- **User Management**: Complete user account management and profile editing
|
||||
- **Console Interface**: Interactive console for system operations
|
||||
- **System Monitoring**: Real-time monitoring of service health and performance
|
||||
- **Blockchain Integration**: Direct integration with Sonr Blockchain x/svc module
|
||||
- **Authentication**: WebAuthn-based secure authentication
|
||||
- **Responsive Design**: Mobile-first approach with modern UI/UX
|
||||
|
||||
## Development
|
||||
|
||||
To start the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This will start the Next.js development server with hot reload at `http://localhost:3001`.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Test dashboard functionality
|
||||
# Visit http://localhost:3001 and test management features
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy to Cloudflare Pages:
|
||||
|
||||
```bash
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
The app is configured for static export and optimized for Cloudflare Pages deployment.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# API endpoints
|
||||
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
|
||||
NEXT_PUBLIC_BLOCKCHAIN_URL=https://blockchain.yourdomain.com
|
||||
|
||||
# Development
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_BLOCKCHAIN_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### 🔮 **Future Implementation** (Planned)
|
||||
|
||||
- [ ] **Dashboard Framework**: Next.js 14 setup with TypeScript configuration
|
||||
- [ ] **User Management**: User account creation, editing, and deletion
|
||||
- [ ] **Console Interface**: Interactive console for system operations
|
||||
- [ ] **System Monitoring**: Real-time service health and performance metrics
|
||||
- [ ] **Blockchain Integration**: Direct integration with Sonr Blockchain x/svc module
|
||||
- [ ] **Authentication**: WebAuthn-based secure authentication
|
||||
- [ ] **Responsive Design**: Mobile-first approach with modern UI/UX
|
||||
|
||||
### 🚧 **Production Readiness** (Next)
|
||||
|
||||
- [ ] **Performance Optimization**: Code splitting and bundle optimization
|
||||
- [ ] **Testing Suite**: Unit tests for components and hooks
|
||||
- [ ] **E2E Testing**: Cypress or Playwright for full user flow testing
|
||||
- [ ] **Error Boundaries**: React error boundaries for graceful error handling
|
||||
- [ ] **Analytics**: User behavior tracking and conversion metrics
|
||||
- [ ] **Security**: Role-based access control and audit logging
|
||||
|
||||
### 🔮 **Future Enhancements**
|
||||
|
||||
- [ ] **Advanced Analytics**: Detailed system analytics and reporting
|
||||
- [ ] **Multi-tenant Support**: Organization-based dashboard isolation
|
||||
- [ ] **Advanced UI**: Dark mode, animations, and enhanced UX
|
||||
- [ ] **Mobile App**: React Native or native mobile applications
|
||||
- [ ] **API Management**: Advanced API key management and monitoring
|
||||
- [ ] **Backup & Recovery**: System backup and disaster recovery features
|
||||
|
||||
## Architecture
|
||||
|
||||
The dashboard follows modern React patterns:
|
||||
|
||||
- **App Router**: Next.js 13+ app directory structure
|
||||
- **Custom Hooks**: Reusable logic for dashboard operations
|
||||
- **Component Library**: Shared UI components from `@sonr.io/ui`
|
||||
- **State Management**: React hooks for local state management
|
||||
- **Styling**: Tailwind CSS with custom dashboard styling
|
||||
|
||||
## Security
|
||||
|
||||
- **WebAuthn Authentication**: Secure admin authentication
|
||||
- **Role-based Access**: Different permission levels for dashboard features
|
||||
- **Input Validation**: Client-side validation with server-side verification
|
||||
- **Audit Logging**: Complete audit trail for dashboard operations
|
||||
- **Session Management**: Secure session handling and timeout
|
||||
|
||||
## Integration
|
||||
|
||||
The dashboard integrates with:
|
||||
|
||||
- **API Gateway**: Provides data for dashboard views
|
||||
- **Blockchain Service**: Direct blockchain operations and monitoring
|
||||
- **Monitoring**: System metrics and performance data
|
||||
- **Authentication**: User authentication and session management
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||
"extends": ["../../biome.json"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@sonr.io/ui/lib/utils",
|
||||
"ui": "@sonr.io/ui/components"
|
||||
},
|
||||
"iconLibrary": "lucide-react"
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,90 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// OpenNext handles the runtime configuration
|
||||
// Removed experimental.runtime and output configuration
|
||||
|
||||
transpilePackages: ['@sonr.io/ui', '@sonr.io/shared', '@sonr.io/es', '@sonr.io/sdk'],
|
||||
|
||||
// API proxy configuration for development
|
||||
async rewrites() {
|
||||
// Only apply in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return [
|
||||
{
|
||||
source: '/api/chain/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_CHAIN_ENDPOINT || 'http://localhost:26657'}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/rpc/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_RPC_ENDPOINT || 'http://localhost:1317'}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/grpc/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_GRPC_ENDPOINT || 'http://localhost:9090'}/:path*`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
// CORS headers for API routes
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value:
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Environment variables passed to the client
|
||||
env: {
|
||||
NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID || 'sonrtest_1-1',
|
||||
NEXT_PUBLIC_AUTH_URL: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3001',
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
|
||||
// Webpack configuration
|
||||
webpack: (config) => {
|
||||
// Handle WebAssembly modules
|
||||
config.experiments = {
|
||||
...config.experiments,
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
};
|
||||
|
||||
// Ignore optional dependencies warnings
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
net: false,
|
||||
tls: false,
|
||||
crypto: false,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
// TypeScript and ESLint configuration
|
||||
typescript: {
|
||||
// Allow production builds to succeed even if there are type errors
|
||||
ignoreBuildErrors: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Warning: This allows production builds to successfully complete even if
|
||||
// your project has ESLint errors.
|
||||
ignoreDuringBuilds: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
export default defineCloudflareConfig();
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@sonr.io/dash",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"build": "next build",
|
||||
"build:cloudflare": "wrangler build",
|
||||
"start": "next start",
|
||||
"preview": "wrangler preview",
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format . --write",
|
||||
"check": "biome check . --write",
|
||||
"deploy:cloudflare": "wrangler deploy",
|
||||
"release": "cz --no-raise 6,21 bump --yes --increment PATCH"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@simplewebauthn/browser": "^9.0.0",
|
||||
"@sonr.io/es": "workspace:*",
|
||||
"@sonr.io/sdk": "workspace:*",
|
||||
"@sonr.io/com": "workspace:*",
|
||||
"@sonr.io/ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.59.20",
|
||||
"@tanstack/react-query-devtools": "^5.59.20",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"lucide-react": "^0.263.1",
|
||||
"next": "15.5.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"recharts": "^2.10.3",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.2",
|
||||
"@browser-echo/next": "^1.0.1",
|
||||
"@cloudflare/next-on-pages": "^1.13.16",
|
||||
"@opennextjs/cloudflare": "^1.8.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vercel": "^47.0.5",
|
||||
"wrangler": "^4.34.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import type { Domain, DomainVerificationStatus } from '@sonr.io/com/types/domain';
|
||||
import {
|
||||
DNSInstructions,
|
||||
DomainList,
|
||||
DomainSelector,
|
||||
VerificationStatus,
|
||||
VerificationWizard,
|
||||
} from '@sonr.io/ui';
|
||||
import { DashboardContent, DashboardHeader } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui/components/ui/alert';
|
||||
import { Badge } from '@sonr.io/ui/components/ui/badge';
|
||||
import { Button } from '@sonr.io/ui/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@sonr.io/ui/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@sonr.io/ui/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@sonr.io/ui/components/ui/tabs';
|
||||
import { Globe, Plus, RefreshCw, Shield } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Domain Management Page
|
||||
* Handles domain verification flow, status tracking, and DNS instructions
|
||||
*/
|
||||
export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState<string | null>(null);
|
||||
const [_isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showVerificationWizard, setShowVerificationWizard] = useState(false);
|
||||
const [verificationStatus, setVerificationStatus] = useState<DomainVerificationStatus | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Fetch domains on mount
|
||||
useEffect(() => {
|
||||
fetchDomains();
|
||||
}, []);
|
||||
|
||||
const fetchDomains = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
const mockDomains: Domain[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'example.com',
|
||||
status: 'verified',
|
||||
verifiedAt: new Date('2024-01-15'),
|
||||
txtRecord: 'sonr-verify=abc123def456',
|
||||
serviceCount: 3,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'api.example.com',
|
||||
status: 'pending',
|
||||
verifiedAt: null,
|
||||
txtRecord: 'sonr-verify=xyz789ghi012',
|
||||
serviceCount: 0,
|
||||
},
|
||||
];
|
||||
setDomains(mockDomains);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshVerificationStatus = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// TODO: Replace with actual API call to check DNS records
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Mock status update
|
||||
const updatedStatus: DomainVerificationStatus = {
|
||||
isVerified: false,
|
||||
lastChecked: new Date(),
|
||||
dnsRecordsFound: true,
|
||||
expectedRecord: 'sonr-verify=abc123def456',
|
||||
};
|
||||
setVerificationStatus(updatedStatus);
|
||||
|
||||
// Refresh domain list
|
||||
await fetchDomains();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh verification status:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainVerification = async (domain: string) => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
console.log('Starting verification for:', domain);
|
||||
setShowVerificationWizard(false);
|
||||
await fetchDomains();
|
||||
} catch (error) {
|
||||
console.error('Failed to verify domain:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainSelection = (domainId: string) => {
|
||||
setSelectedDomain(domainId);
|
||||
const domain = domains.find((d) => d.id === domainId);
|
||||
if (domain && domain.status === 'pending') {
|
||||
refreshVerificationStatus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<DashboardHeader
|
||||
title="Domain Management"
|
||||
description="Verify and manage your domains for service registration"
|
||||
>
|
||||
<Dialog open={showVerificationWizard} onOpenChange={setShowVerificationWizard}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Domain
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<VerificationWizard
|
||||
onComplete={handleDomainVerification}
|
||||
onCancel={() => setShowVerificationWizard(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardHeader>
|
||||
|
||||
<DashboardContent>
|
||||
<div className="grid gap-6">
|
||||
{/* Domain Overview Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Domains</CardTitle>
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{domains.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{domains.filter((d) => d.status === 'verified').length} verified
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Pending Verification</CardTitle>
|
||||
<RefreshCw className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{domains.filter((d) => d.status === 'pending').length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Awaiting DNS verification</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Services</CardTitle>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{domains.reduce((sum, d) => sum + (d.serviceCount || 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Across all domains</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Domain Management Tabs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Domains</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your verified domains and track verification status
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="all" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All Domains</TabsTrigger>
|
||||
<TabsTrigger value="verified">Verified</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<DomainList
|
||||
domains={domains}
|
||||
onDomainSelect={handleDomainSelection}
|
||||
onRefresh={refreshVerificationStatus}
|
||||
isRefreshing={isRefreshing}
|
||||
selectedDomainId={selectedDomain}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="verified" className="space-y-4">
|
||||
<DomainList
|
||||
domains={domains.filter((d) => d.status === 'verified')}
|
||||
onDomainSelect={handleDomainSelection}
|
||||
onRefresh={refreshVerificationStatus}
|
||||
isRefreshing={isRefreshing}
|
||||
selectedDomainId={selectedDomain}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pending" className="space-y-4">
|
||||
<DomainList
|
||||
domains={domains.filter((d) => d.status === 'pending')}
|
||||
onDomainSelect={handleDomainSelection}
|
||||
onRefresh={refreshVerificationStatus}
|
||||
isRefreshing={isRefreshing}
|
||||
selectedDomainId={selectedDomain}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DNS Instructions for Selected Domain */}
|
||||
{selectedDomain &&
|
||||
domains.find((d) => d.id === selectedDomain && d.status === 'pending') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DNS Verification Instructions</CardTitle>
|
||||
<CardDescription>
|
||||
Add the following TXT record to your domain's DNS settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DNSInstructions
|
||||
domain={domains.find((d) => d.id === selectedDomain)?.name || ''}
|
||||
txtRecord={domains.find((d) => d.id === selectedDomain)?.txtRecord || ''}
|
||||
/>
|
||||
|
||||
{verificationStatus && (
|
||||
<div className="mt-4">
|
||||
<VerificationStatus
|
||||
status={verificationStatus}
|
||||
domain={domains.find((d) => d.id === selectedDomain)?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={refreshVerificationStatus}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Checking DNS Records...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Check Verification Status
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Domain Selector for Service Registration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Select a verified domain to register new services</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<DomainSelector
|
||||
domains={domains.filter((d) => d.status === 'verified')}
|
||||
onSelect={(domain) => {
|
||||
// Navigate to service registration with selected domain
|
||||
window.location.href = `/services/new?domain=${domain}`;
|
||||
}}
|
||||
placeholder="Select a verified domain"
|
||||
/>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Only verified domains can be used to register new services. Complete domain
|
||||
verification first.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional dashboard-specific styles */
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Additional app-specific styles */
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DashboardSidebar, SidebarInset, SidebarProvider, SidebarTrigger } from '@sonr.io/ui';
|
||||
import type { Metadata } from 'next';
|
||||
import { AuthWrapper } from '../components/auth-wrapper';
|
||||
import { ThemeProvider } from '../components/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sonr Developer Dashboard',
|
||||
description: 'Manage your Sonr Services, domains, and analytics',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<ThemeProvider defaultTheme="system" storageKey="sonr-dashboard-theme">
|
||||
<AuthWrapper>
|
||||
<SidebarProvider>
|
||||
<div className="relative flex min-h-screen w-full">
|
||||
<DashboardSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<h1 className="text-lg font-semibold">Sonr Dashboard</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 px-4 py-6 lg:px-8">{children}</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</AuthWrapper>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@sonr.io/ui/components/ui/card';
|
||||
import { Activity, Clock, Server, Users } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function DashboardHome() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState({
|
||||
totalServices: 0,
|
||||
activeServices: 0,
|
||||
totalRequests: 0,
|
||||
avgResponseTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate loading and fetching metrics
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
setMetrics({
|
||||
totalServices: 12,
|
||||
activeServices: 8,
|
||||
totalRequests: 24658,
|
||||
avgResponseTime: 142,
|
||||
});
|
||||
} catch (_err) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Sonr Developer Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">Manage your services, domains, and analytics</p>
|
||||
</div>
|
||||
|
||||
{/* Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<MetricCard
|
||||
title="Total Services"
|
||||
value={isLoading ? '...' : metrics.totalServices.toString()}
|
||||
icon={<Server className="h-6 w-6" />}
|
||||
trend="+12%"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Active Services"
|
||||
value={isLoading ? '...' : metrics.activeServices.toString()}
|
||||
icon={<Activity className="h-6 w-6" />}
|
||||
trend="+8%"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Requests"
|
||||
value={isLoading ? '...' : metrics.totalRequests.toLocaleString()}
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
trend="+23%"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Response Time"
|
||||
value={isLoading ? '...' : `${metrics.avgResponseTime}ms`}
|
||||
icon={<Clock className="h-6 w-6" />}
|
||||
trend="-5%"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ActionButton
|
||||
title="Register Service"
|
||||
description="Add a new service to your dashboard"
|
||||
href="/services?action=register"
|
||||
/>
|
||||
<ActionButton
|
||||
title="Verify Domain"
|
||||
description="Verify domain ownership for your services"
|
||||
href="/domains?action=verify"
|
||||
/>
|
||||
<ActionButton
|
||||
title="View Analytics"
|
||||
description="Monitor your service performance and usage"
|
||||
href="/analytics"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<ActivityItem
|
||||
title="Service registered"
|
||||
description="API Gateway service was successfully registered"
|
||||
time="2 hours ago"
|
||||
type="success"
|
||||
/>
|
||||
<ActivityItem
|
||||
title="Domain verified"
|
||||
description="Domain api.example.com verification completed"
|
||||
time="5 hours ago"
|
||||
type="success"
|
||||
/>
|
||||
<ActivityItem
|
||||
title="API key generated"
|
||||
description="New API key created for Data Service"
|
||||
time="1 day ago"
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
trend: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, icon, trend, isLoading }: MetricCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
{isLoading ? (
|
||||
<div className="h-8 bg-muted rounded animate-pulse flex-1" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
)}
|
||||
<div className="text-xs text-green-600 ml-2">{trend}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
function ActionButton({ title, description, href }: ActionButtonProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="block p-4 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">{title}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{description}</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActivityItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
type: 'success' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
function ActivityItem({ title, description, time, type }: ActivityItemProps) {
|
||||
const colors = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`px-2 py-1 rounded-full text-xs ${colors[type]}`}>
|
||||
{type === 'success' ? '✓' : type === 'info' ? 'i' : '!'}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{title}</p>
|
||||
<p className="text-sm text-gray-600">{description}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{time}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function TestEcho() {
|
||||
useEffect(() => {
|
||||
console.log('Testing browser-echo from dashboard');
|
||||
console.info('This is an info message');
|
||||
console.warn('This is a warning message');
|
||||
console.error('This is an error message');
|
||||
console.debug('This is a debug message');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Browser Echo Test</h1>
|
||||
<p>Check the terminal to see if console logs are being streamed!</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log('Button clicked at:', new Date().toISOString());
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Click to test console.log
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@sonr.io/ui';
|
||||
import { CheckCircle, Copy, Eye, EyeOff, Key, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface APIKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
expiresAt?: string;
|
||||
status: 'active' | 'expired' | 'revoked';
|
||||
}
|
||||
|
||||
export function APIKeyManager() {
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Production API Key',
|
||||
key: 'sk_live_abc123...',
|
||||
createdAt: '2024-01-15',
|
||||
lastUsed: '2024-01-20',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Development API Key',
|
||||
key: 'sk_test_xyz789...',
|
||||
createdAt: '2024-01-10',
|
||||
lastUsed: '2024-01-19',
|
||||
status: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
const [showKey, setShowKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// TODO: Implement actual API key creation
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const generatedKey = `sk_${Math.random().toString(36).substring(2, 15)}`;
|
||||
setNewKey(generatedKey);
|
||||
|
||||
const newApiKey: APIKey = {
|
||||
id: Date.now().toString(),
|
||||
name: newKeyName,
|
||||
key: generatedKey,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
setApiKeys((prev) => [...prev, newApiKey]);
|
||||
setNewKeyName('');
|
||||
} catch (error) {
|
||||
console.error('Failed to create API key:', error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
const handleRevokeKey = async (keyId: string) => {
|
||||
try {
|
||||
// TODO: Implement actual API key revocation
|
||||
setApiKeys((prev) =>
|
||||
prev.map((key) => (key.id === keyId ? { ...key, status: 'revoked' as const } : key))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateKey = async (keyId: string) => {
|
||||
try {
|
||||
// TODO: Implement actual API key regeneration
|
||||
const newKey = `sk_${Math.random().toString(36).substring(2, 15)}`;
|
||||
setApiKeys((prev) => prev.map((key) => (key.id === keyId ? { ...key, key: newKey } : key)));
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage API keys for authenticating requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create API Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New API Key</DialogTitle>
|
||||
<DialogDescription>Generate a new API key for your service</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!newKey ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="keyName" className="text-sm font-medium">
|
||||
Key Name
|
||||
</label>
|
||||
<Input
|
||||
id="keyName"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g., Production API Key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateKey}
|
||||
disabled={!newKeyName || isCreating}
|
||||
className="w-full"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Key'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
API key created successfully! Copy it now as it won't be shown again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="generatedKey" className="text-sm font-medium">
|
||||
Your API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code id="generatedKey" className="flex-1 p-2 text-xs font-mono">
|
||||
{newKey}
|
||||
</Code>
|
||||
<Button size="sm" variant="outline" onClick={() => handleCopyKey(newKey)}>
|
||||
{copiedKey === newKey ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateDialog(false);
|
||||
setNewKey(null);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* API Keys Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Active API Keys</CardTitle>
|
||||
<CardDescription>View and manage your service API keys</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apiKeys.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.id}>
|
||||
<TableCell className="font-medium">{apiKey.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs">
|
||||
{showKey === apiKey.id ? apiKey.key : `${apiKey.key.substring(0, 10)}...`}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowKey(showKey === apiKey.id ? null : apiKey.id)}
|
||||
>
|
||||
{showKey === apiKey.id ? (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleCopyKey(apiKey.key)}>
|
||||
{copiedKey === apiKey.key ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{apiKey.createdAt}</TableCell>
|
||||
<TableCell>{apiKey.lastUsed || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
apiKey.status === 'active'
|
||||
? 'success'
|
||||
: apiKey.status === 'expired'
|
||||
? 'warning'
|
||||
: 'destructive'
|
||||
}
|
||||
>
|
||||
{apiKey.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRegenerateKey(apiKey.id)}
|
||||
disabled={apiKey.status === 'revoked'}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRevokeKey(apiKey.id)}
|
||||
disabled={apiKey.status === 'revoked'}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Key className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">No API keys created yet</p>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>Create Your First API Key</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Use your API key in the <code>Authorization</code> header:{' '}
|
||||
<code>Bearer YOUR_API_KEY</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`bg-muted rounded px-2 py-1 font-mono ${className || ''}`}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, Badge, Button, Card, CardContent, Progress } from '@sonr.io/ui';
|
||||
import { CheckCircle, Clock, Copy, ExternalLink, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface DomainVerificationStatusProps {
|
||||
domain: string;
|
||||
status?: 'verified' | 'pending' | 'failed' | 'unverified';
|
||||
}
|
||||
|
||||
export function DomainVerificationStatus({
|
||||
domain,
|
||||
status = 'unverified',
|
||||
}: DomainVerificationStatusProps) {
|
||||
const [verificationStatus, setVerificationStatus] = useState(status);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
const txtRecord = `sonr-verify=${domain.replace(/^https?:\/\//, '')}-${Date.now()}`;
|
||||
|
||||
const handleVerify = async () => {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
// TODO: Implement actual domain verification API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setVerificationStatus('verified');
|
||||
} catch (_error) {
|
||||
setVerificationStatus('failed');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTxtRecord = () => {
|
||||
navigator.clipboard.writeText(txtRecord);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (verificationStatus) {
|
||||
case 'verified':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-5 w-5 text-yellow-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (verificationStatus) {
|
||||
case 'verified':
|
||||
return <Badge variant="success">Verified</Badge>;
|
||||
case 'pending':
|
||||
return <Badge variant="warning">Pending</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Failed</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">Unverified</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Status Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon()}
|
||||
<div>
|
||||
<p className="font-medium">{domain}</p>
|
||||
<p className="text-sm text-muted-foreground">Domain ownership verification</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
{/* Verification Steps */}
|
||||
{verificationStatus !== 'verified' && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h4 className="font-medium mb-4">Verification Steps</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">1. Add TXT Record to DNS</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 text-xs bg-muted rounded font-mono">{txtRecord}</code>
|
||||
<Button size="sm" variant="outline" onClick={handleCopyTxtRecord}>
|
||||
{copySuccess ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">2. Wait for DNS Propagation</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This typically takes 5-30 minutes but can take up to 48 hours
|
||||
</p>
|
||||
<Progress value={33} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">3. Verify Domain Ownership</p>
|
||||
<Button onClick={handleVerify} disabled={isVerifying} className="w-full">
|
||||
{isVerifying ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify Now'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{verificationStatus === 'verified' && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Domain verification successful! Your service is now linked to {domain}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Failed Message */}
|
||||
{verificationStatus === 'failed' && (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Domain verification failed. Please check your DNS records and try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Help Link */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<a
|
||||
href="/docs/domain-verification"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
Domain verification help
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
|
||||
{verificationStatus === 'pending' && (
|
||||
<Button variant="ghost" size="sm" onClick={handleVerify}>
|
||||
<RefreshCw className="mr-2 h-3 w-3" />
|
||||
Check Status
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@sonr.io/ui';
|
||||
import { Edit, Info, Plus, Shield, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
granted: boolean;
|
||||
}
|
||||
|
||||
interface PermissionManagerProps {
|
||||
serviceId: string;
|
||||
permissions?: Permission[];
|
||||
}
|
||||
|
||||
export function PermissionManager({ permissions = [] }: PermissionManagerProps) {
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
|
||||
const permissionScopes = {
|
||||
'read:profile': 'Read user profile information',
|
||||
'write:profile': 'Update user profile',
|
||||
'read:data': 'Access user data',
|
||||
'write:data': 'Modify user data',
|
||||
'read:credentials': 'View credentials',
|
||||
'manage:credentials': 'Create and manage credentials',
|
||||
'read:vault': 'Access vault contents',
|
||||
'manage:vault': 'Full vault management',
|
||||
'execute:transactions': 'Execute blockchain transactions',
|
||||
'delegate:permissions': 'Delegate permissions to others',
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permissionId: string) => {
|
||||
setSelectedPermissions((prev) =>
|
||||
prev.includes(permissionId)
|
||||
? prev.filter((id) => id !== permissionId)
|
||||
: [...prev, permissionId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
try {
|
||||
// TODO: Implement API call to save permissions
|
||||
console.log('Saving permissions:', selectedPermissions);
|
||||
} catch (error) {
|
||||
console.error('Failed to save permissions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Permission Summary */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Current Permissions</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permissions.filter((p) => p.granted).length} of {permissions.length} permissions
|
||||
granted
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Permission
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Permission</DialogTitle>
|
||||
<DialogDescription>Select permissions to add to your service</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 mt-4">
|
||||
{Object.entries(permissionScopes).map(([scope, description]) => (
|
||||
<label
|
||||
key={scope}
|
||||
htmlFor={`permission-${scope}`}
|
||||
className="flex items-start space-x-3 p-3 rounded-lg border cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`permission-${scope}`}
|
||||
checked={selectedPermissions.includes(scope)}
|
||||
onCheckedChange={() => handlePermissionToggle(scope)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm">{scope}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions}>Add Permissions</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* UCAN Capabilities */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">UCAN Capabilities</CardTitle>
|
||||
<CardDescription>User-Controlled Authorization Network permissions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{permissions.length > 0 ? (
|
||||
permissions.map((permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{permission.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{permission.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={permission.granted ? 'success' : 'secondary'}>
|
||||
{permission.granted ? 'Granted' : 'Pending'}
|
||||
</Badge>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
No permissions configured yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Permission Audit Log */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Permission Audit Log</CardTitle>
|
||||
<CardDescription>Recent permission changes and access attempts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
id: 'log1',
|
||||
action: 'Permission granted',
|
||||
scope: 'read:profile',
|
||||
time: '2 hours ago',
|
||||
},
|
||||
{ id: 'log2', action: 'Permission revoked', scope: 'write:data', time: '1 day ago' },
|
||||
{ id: 'log3', action: 'Access attempted', scope: 'manage:vault', time: '3 days ago' },
|
||||
].map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{log.action}:</span>{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">{log.scope}</code>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{log.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Permissions are managed through UCAN tokens. Changes may take up to 5 minutes to
|
||||
propagate.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Stepper,
|
||||
StepperItem,
|
||||
Textarea,
|
||||
} from '@sonr.io/ui';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(3, 'Service name must be at least 3 characters'),
|
||||
description: z.string().min(10, 'Description must be at least 10 characters'),
|
||||
domain: z.string().url('Must be a valid domain'),
|
||||
category: z.string().min(1, 'Please select a category'),
|
||||
permissions: z.array(z.string()).min(1, 'Select at least one permission'),
|
||||
});
|
||||
|
||||
type ServiceFormData = z.infer<typeof serviceSchema>;
|
||||
|
||||
interface ServiceRegistrationFormProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceRegistrationForm({ onSuccess }: ServiceRegistrationFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<ServiceFormData>({
|
||||
resolver: zodResolver(serviceSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
domain: '',
|
||||
category: '',
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ServiceFormData) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual service registration API call
|
||||
const response = await fetch('/api/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to register service');
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ title: 'Basic Info', description: 'Service name and description' },
|
||||
{ title: 'Domain', description: 'Configure domain verification' },
|
||||
{ title: 'Permissions', description: 'Set required permissions' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1: Basic Info */}
|
||||
<div className={currentStep === 0 ? 'block' : 'hidden'}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome Service" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A unique name for your service</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe what your service does..."
|
||||
{...field}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Help users understand your service's purpose</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="api">API Service</SelectItem>
|
||||
<SelectItem value="webapp">Web Application</SelectItem>
|
||||
<SelectItem value="mobile">Mobile App</SelectItem>
|
||||
<SelectItem value="iot">IoT Device</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Choose the category that best fits your service</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Domain */}
|
||||
<div className={currentStep === 1 ? 'block' : 'hidden'}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>The domain where your service is hosted</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
After registration, you'll need to verify domain ownership by adding a TXT record to
|
||||
your DNS.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Permissions */}
|
||||
<div className={currentStep === 2 ? 'block' : 'hidden'}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permissions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Required Permissions</FormLabel>
|
||||
<div className="space-y-2 mt-2">
|
||||
{['read:profile', 'write:data', 'read:credentials', 'manage:vault'].map(
|
||||
(permission) => (
|
||||
<label
|
||||
key={permission}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={permission}
|
||||
checked={field.value?.includes(permission)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const current = field.value || [];
|
||||
if (e.target.checked) {
|
||||
field.onChange([...current, value]);
|
||||
} else {
|
||||
field.onChange(current.filter((v) => v !== value));
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{permission}</span>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<FormDescription className="mt-2">
|
||||
Select the permissions your service requires
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentStep((prev) => Math.max(0, prev - 1))}
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{currentStep < steps.length - 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep((prev) => Math.min(steps.length - 1, prev + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
'Register Service'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AuthWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthWrapper({ children }: AuthWrapperProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status
|
||||
const checkAuth = () => {
|
||||
// In development mode, skip authentication for easier testing
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setIsAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for auth token in localStorage or cookie
|
||||
const authToken =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('sonr_auth_token') : null;
|
||||
|
||||
if (!authToken) {
|
||||
// Redirect to auth app
|
||||
window.location.href = process.env.NEXT_PUBLIC_AUTH_URL || 'https://auth.sonr.io';
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Show loading while checking auth
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show children if authenticated
|
||||
if (isAuthenticated) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Should not reach here due to redirect
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'sonr-ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
((typeof window !== 'undefined' && localStorage.getItem(storageKey)) as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { DomainVerification } from '@sonr.io/com/types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { svcApi } from '../lib/api';
|
||||
|
||||
interface UseDomainVerificationReturn {
|
||||
verification: DomainVerification | null;
|
||||
isVerifying: boolean;
|
||||
isPolling: boolean;
|
||||
error: Error | null;
|
||||
startVerification: (domain: string) => Promise<void>;
|
||||
checkStatus: (domain: string) => Promise<void>;
|
||||
startPolling: (domain: string) => void;
|
||||
stopPolling: () => void;
|
||||
}
|
||||
|
||||
interface PollingConfig {
|
||||
interval?: number;
|
||||
maxAttempts?: number;
|
||||
onSuccess?: (verification: DomainVerification) => void;
|
||||
onFailure?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for domain verification with polling support
|
||||
*/
|
||||
export function useDomainVerification(
|
||||
initialDomain?: string,
|
||||
config?: PollingConfig
|
||||
): UseDomainVerificationReturn {
|
||||
const [verification, setVerification] = useState<DomainVerification | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pollingAttemptsRef = useRef(0);
|
||||
const currentDomainRef = useRef<string | null>(initialDomain || null);
|
||||
|
||||
const { interval = 5000, maxAttempts = 60, onSuccess, onFailure } = config || {};
|
||||
|
||||
/**
|
||||
* Check domain verification status once
|
||||
*/
|
||||
const checkStatus = useCallback(
|
||||
async (domain: string) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const status = await svcApi.checkDomainStatus(domain);
|
||||
|
||||
if (!status) {
|
||||
throw new Error('Failed to fetch domain status');
|
||||
}
|
||||
|
||||
setVerification(status);
|
||||
|
||||
// Check if verification is complete
|
||||
if (status.status === 'DOMAIN_VERIFICATION_STATUS_VERIFIED') {
|
||||
onSuccess?.(status);
|
||||
stopPolling();
|
||||
} else if (status.status === 'DOMAIN_VERIFICATION_STATUS_FAILED') {
|
||||
const error = new Error('Domain verification failed');
|
||||
setError(error);
|
||||
onFailure?.(error);
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
return status;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to check domain status');
|
||||
setError(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[onSuccess, onFailure]
|
||||
);
|
||||
|
||||
/**
|
||||
* Start domain verification process
|
||||
*/
|
||||
const startVerification = useCallback(
|
||||
async (domain: string) => {
|
||||
setIsVerifying(true);
|
||||
setError(null);
|
||||
currentDomainRef.current = domain;
|
||||
|
||||
try {
|
||||
// In a real implementation, this would initiate the verification
|
||||
// For now, we just start polling the status
|
||||
await checkStatus(domain);
|
||||
startPolling(domain);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to start verification');
|
||||
setError(error);
|
||||
onFailure?.(error);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
},
|
||||
[checkStatus, onFailure]
|
||||
);
|
||||
|
||||
/**
|
||||
* Start polling for verification status
|
||||
*/
|
||||
const startPolling = useCallback(
|
||||
(domain: string) => {
|
||||
// Clear any existing polling
|
||||
stopPolling();
|
||||
|
||||
setIsPolling(true);
|
||||
pollingAttemptsRef.current = 0;
|
||||
currentDomainRef.current = domain;
|
||||
|
||||
// Set up polling interval
|
||||
pollingIntervalRef.current = setInterval(async () => {
|
||||
pollingAttemptsRef.current++;
|
||||
|
||||
// Check if we've exceeded max attempts
|
||||
if (pollingAttemptsRef.current >= maxAttempts) {
|
||||
const error = new Error('Domain verification timeout');
|
||||
setError(error);
|
||||
onFailure?.(error);
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkStatus(domain);
|
||||
} catch (err) {
|
||||
// Continue polling even if individual checks fail
|
||||
console.error('Polling check failed:', err);
|
||||
|
||||
// Stop polling after multiple consecutive failures
|
||||
if (pollingAttemptsRef.current > 3) {
|
||||
const error = err instanceof Error ? err : new Error('Polling failed');
|
||||
setError(error);
|
||||
onFailure?.(error);
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
},
|
||||
[interval, maxAttempts, checkStatus, onFailure]
|
||||
);
|
||||
|
||||
/**
|
||||
* Stop polling
|
||||
*/
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
setIsPolling(false);
|
||||
pollingAttemptsRef.current = 0;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clean up on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
/**
|
||||
* Auto-start polling if initial domain is provided
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialDomain) {
|
||||
startVerification(initialDomain);
|
||||
}
|
||||
}, [initialDomain, startVerification]);
|
||||
|
||||
return {
|
||||
verification,
|
||||
isVerifying,
|
||||
isPolling,
|
||||
error,
|
||||
startVerification,
|
||||
checkStatus,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing multiple domain verifications
|
||||
*/
|
||||
export function useDomainVerifications() {
|
||||
const [verifications, setVerifications] = useState<Map<string, DomainVerification>>(new Map());
|
||||
const [activePolls, setActivePolls] = useState<Set<string>>(new Set());
|
||||
|
||||
const addVerification = useCallback((domain: string, verification: DomainVerification) => {
|
||||
setVerifications((prev) => new Map(prev).set(domain, verification));
|
||||
}, []);
|
||||
|
||||
const removeVerification = useCallback((domain: string) => {
|
||||
setVerifications((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(domain);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startPollingFor = useCallback((domain: string) => {
|
||||
setActivePolls((prev) => new Set(prev).add(domain));
|
||||
}, []);
|
||||
|
||||
const stopPollingFor = useCallback((domain: string) => {
|
||||
setActivePolls((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(domain);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
verifications: Array.from(verifications.values()),
|
||||
activePolls: Array.from(activePolls),
|
||||
addVerification,
|
||||
removeVerification,
|
||||
startPollingFor,
|
||||
stopPollingFor,
|
||||
isPolling: (domain: string) => activePolls.has(domain),
|
||||
getVerification: (domain: string) => verifications.get(domain),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { Service } from '@sonr.io/com';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { svcApi } from '../lib/api';
|
||||
|
||||
interface UseServiceReturn {
|
||||
service: Service | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
mutate: (updates: Partial<Service>) => void;
|
||||
subscribe: (callback: (service: Service) => void) => () => void;
|
||||
}
|
||||
|
||||
// Service cache for optimistic updates
|
||||
const serviceCache = new Map<string, Service>();
|
||||
|
||||
// Subscribers for real-time updates
|
||||
const serviceSubscribers = new Map<string, Set<(service: Service) => void>>();
|
||||
|
||||
/**
|
||||
* Hook for individual service data with real-time updates
|
||||
*/
|
||||
export function useService(serviceId: string): UseServiceReturn {
|
||||
const [service, setService] = useState<Service | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch service with error recovery
|
||||
*/
|
||||
const fetchService = useCallback(async () => {
|
||||
if (!serviceId) {
|
||||
setError(new Error('Service ID is required'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const cached = serviceCache.get(serviceId);
|
||||
if (cached) {
|
||||
setService(cached);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchedService = await svcApi.getServiceDetails(serviceId);
|
||||
|
||||
if (!fetchedService) {
|
||||
throw new Error('Service not found');
|
||||
}
|
||||
|
||||
// Update cache
|
||||
serviceCache.set(serviceId, fetchedService);
|
||||
|
||||
// Update state
|
||||
setService(fetchedService);
|
||||
|
||||
// Notify subscribers
|
||||
const subscribers = serviceSubscribers.get(serviceId);
|
||||
if (subscribers) {
|
||||
for (const callback of subscribers) {
|
||||
callback(fetchedService);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
|
||||
// Retry with exponential backoff
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchService();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Use mock data for development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const mockService = getMockService(serviceId);
|
||||
setService(mockService);
|
||||
serviceCache.set(serviceId, mockService);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [serviceId]);
|
||||
|
||||
/**
|
||||
* Optimistic update
|
||||
*/
|
||||
const mutate = useCallback(
|
||||
(updates: Partial<Service>) => {
|
||||
if (!service) return;
|
||||
|
||||
const updated = { ...service, ...updates };
|
||||
|
||||
// Update cache
|
||||
serviceCache.set(serviceId, updated);
|
||||
|
||||
// Update state
|
||||
setService(updated);
|
||||
|
||||
// Notify subscribers
|
||||
const subscribers = serviceSubscribers.get(serviceId);
|
||||
if (subscribers) {
|
||||
for (const callback of subscribers) {
|
||||
callback(updated);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with server in background
|
||||
svcApi
|
||||
.getServiceDetails(serviceId)
|
||||
.then((freshService) => {
|
||||
if (freshService) {
|
||||
serviceCache.set(serviceId, freshService);
|
||||
setService(freshService);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
[service, serviceId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribe to real-time updates
|
||||
*/
|
||||
const subscribe = useCallback(
|
||||
(callback: (service: Service) => void) => {
|
||||
if (!serviceSubscribers.has(serviceId)) {
|
||||
serviceSubscribers.set(serviceId, new Set());
|
||||
}
|
||||
|
||||
const subscribers = serviceSubscribers.get(serviceId);
|
||||
if (subscribers) {
|
||||
subscribers.add(callback);
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
serviceSubscribers.delete(serviceId);
|
||||
}
|
||||
};
|
||||
},
|
||||
[serviceId]
|
||||
);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchService();
|
||||
}, [fetchService]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up real-time updates (WebSocket in production)
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// TODO: Implement WebSocket connection for real-time updates
|
||||
// const ws = new WebSocket(`${wsEndpoint}/services/${serviceId}`);
|
||||
// ws.onmessage = (event) => {
|
||||
// const updatedService = JSON.parse(event.data);
|
||||
// serviceCache.set(serviceId, updatedService);
|
||||
// setService(updatedService);
|
||||
// };
|
||||
}
|
||||
|
||||
// Simulate real-time updates in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const interval = setInterval(() => {
|
||||
if (service) {
|
||||
const updated = {
|
||||
...service,
|
||||
metadata: {
|
||||
...service.metadata,
|
||||
totalRequests:
|
||||
(service.metadata?.totalRequests || 0) + Math.floor(Math.random() * 100),
|
||||
},
|
||||
};
|
||||
mutate(updated);
|
||||
}
|
||||
}, 30000); // Update every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [service, mutate]);
|
||||
|
||||
return {
|
||||
service,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchService,
|
||||
mutate,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock service data for development
|
||||
*/
|
||||
function getMockService(serviceId: string): Service {
|
||||
return {
|
||||
id: serviceId,
|
||||
name: 'My API Service',
|
||||
description: 'A powerful API service for data processing and management',
|
||||
domain: 'api.example.com',
|
||||
status: 'active',
|
||||
owner: 'did:sonr:alice',
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
permissions: [
|
||||
{
|
||||
id: 'perm_1',
|
||||
name: 'read:data',
|
||||
description: 'Read user data',
|
||||
scope: 'data',
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
id: 'perm_2',
|
||||
name: 'write:data',
|
||||
description: 'Write user data',
|
||||
scope: 'data',
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
id: 'perm_3',
|
||||
name: 'manage:vault',
|
||||
description: 'Manage vault contents',
|
||||
scope: 'vault',
|
||||
granted: false,
|
||||
},
|
||||
],
|
||||
apiKeys: [
|
||||
{
|
||||
id: 'key_1',
|
||||
name: 'Production Key',
|
||||
lastUsed: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
domainVerificationStatus: 'verified',
|
||||
metadata: {
|
||||
totalRequests: 142523,
|
||||
activeUsers: 1250,
|
||||
averageLatency: 45,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface ServiceMetrics {
|
||||
totalRequests: number;
|
||||
successRate: number;
|
||||
averageLatency: number;
|
||||
errorRate: number;
|
||||
requestsPerMinute: number[];
|
||||
topEndpoints: Array<{
|
||||
endpoint: string;
|
||||
count: number;
|
||||
averageLatency: number;
|
||||
}>;
|
||||
dailyStats: Array<{
|
||||
date: string;
|
||||
requests: number;
|
||||
errors: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UseServiceMetricsReturn {
|
||||
metrics: ServiceMetrics | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useServiceMetrics(serviceId: string): UseServiceMetricsReturn {
|
||||
const [metrics, setMetrics] = useState<ServiceMetrics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchMetrics = useCallback(async () => {
|
||||
if (!serviceId) {
|
||||
setError(new Error('Service ID is required'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
const response = await fetch(`/api/services/${serviceId}/metrics`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch metrics');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setMetrics(data.metrics);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
// Mock data for development
|
||||
setMetrics({
|
||||
totalRequests: 142523,
|
||||
successRate: 99.2,
|
||||
averageLatency: 45,
|
||||
errorRate: 0.8,
|
||||
requestsPerMinute: Array.from({ length: 60 }, () => Math.floor(Math.random() * 100) + 20),
|
||||
topEndpoints: [
|
||||
{ endpoint: '/api/v1/data', count: 45230, averageLatency: 32 },
|
||||
{ endpoint: '/api/v1/auth', count: 28450, averageLatency: 28 },
|
||||
{ endpoint: '/api/v1/users', count: 18230, averageLatency: 45 },
|
||||
{ endpoint: '/api/v1/vault', count: 12450, averageLatency: 67 },
|
||||
{ endpoint: '/api/v1/credentials', count: 8230, averageLatency: 52 },
|
||||
],
|
||||
dailyStats: Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
requests: Math.floor(Math.random() * 5000) + 3000,
|
||||
errors: Math.floor(Math.random() * 50) + 10,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [serviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
|
||||
// Poll for updates every 30 seconds
|
||||
const interval = setInterval(fetchMetrics, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchMetrics]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchMetrics,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Service } from '@sonr.io/com';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authApi, svcApi } from '../lib/api';
|
||||
|
||||
interface UseServicesReturn {
|
||||
services: Service[] | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
mutate: (updater: (services: Service[]) => Service[]) => void;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: Service[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
const servicesCache = new Map<string, CacheEntry>();
|
||||
|
||||
export function useServices(): UseServicesReturn {
|
||||
const [services, setServices] = useState<Service[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const maxRetries = 3;
|
||||
|
||||
/**
|
||||
* Fetch services with caching and error recovery
|
||||
*/
|
||||
const fetchServices = useCallback(async (forceRefresh = false) => {
|
||||
const user = authApi.getCurrentUser();
|
||||
if (!user?.address) {
|
||||
setError(new Error('Authentication required'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `services-${user.address}`;
|
||||
|
||||
// Check cache unless force refresh
|
||||
if (!forceRefresh) {
|
||||
const cached = servicesCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
setServices(cached.data);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use the API client to fetch services
|
||||
const fetchedServices = await svcApi.getMyServices(user.address);
|
||||
|
||||
// Update cache
|
||||
servicesCache.set(cacheKey, {
|
||||
data: fetchedServices,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
setServices(fetchedServices);
|
||||
retryCountRef.current = 0; // Reset retry count on success
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch services';
|
||||
|
||||
// Implement exponential backoff for retries
|
||||
if (retryCountRef.current < maxRetries) {
|
||||
retryCountRef.current++;
|
||||
const delay = Math.min(1000 * 2 ** retryCountRef.current, 10000);
|
||||
|
||||
setTimeout(() => {
|
||||
fetchServices(forceRefresh);
|
||||
}, delay);
|
||||
|
||||
setError(
|
||||
new Error(`${errorMessage}. Retrying... (${retryCountRef.current}/${maxRetries})`)
|
||||
);
|
||||
} else {
|
||||
setError(new Error(errorMessage));
|
||||
|
||||
// Fallback to cached data if available
|
||||
const cached = servicesCache.get(cacheKey);
|
||||
if (cached) {
|
||||
setServices(cached.data);
|
||||
} else {
|
||||
// Use mock data as last resort in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setServices(getMockServices());
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Optimistic update function
|
||||
*/
|
||||
const mutate = useCallback((updater: (services: Service[]) => Service[]) => {
|
||||
setServices((current) => {
|
||||
if (!current) return null;
|
||||
const updated = updater(current);
|
||||
|
||||
// Update cache with mutated data
|
||||
const user = authApi.getCurrentUser();
|
||||
if (user?.address) {
|
||||
servicesCache.set(`services-${user.address}`, {
|
||||
data: updated,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initial fetch on mount
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, [fetchServices]);
|
||||
|
||||
// Set up periodic refresh
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchServices(false); // Use cache if valid
|
||||
}, CACHE_DURATION);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchServices]);
|
||||
|
||||
return {
|
||||
services,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: () => fetchServices(true), // Force refresh on manual refetch
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock data for development
|
||||
*/
|
||||
function getMockServices(): Service[] {
|
||||
return [
|
||||
{
|
||||
id: 'svc_1',
|
||||
name: 'My API Service',
|
||||
description: 'A powerful API service for data processing',
|
||||
domain: 'api.example.com',
|
||||
status: 'active',
|
||||
owner: 'did:sonr:alice',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
permissions: ['read:data', 'write:data'],
|
||||
apiKeys: [],
|
||||
domainVerificationStatus: 'verified',
|
||||
},
|
||||
{
|
||||
id: 'svc_2',
|
||||
name: 'Web Application',
|
||||
description: 'Main web application frontend',
|
||||
domain: 'app.example.com',
|
||||
status: 'pending',
|
||||
owner: 'did:sonr:alice',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
permissions: ['read:profile', 'manage:vault'],
|
||||
apiKeys: [],
|
||||
domainVerificationStatus: 'pending',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Temporary stub to fix module resolution - TODO: Replace with actual @sonr.io/es/client import
|
||||
const getAccount = async (params: { address: string; rpcEndpoint: string }): Promise<any> => {
|
||||
console.warn('Using stub implementation for getAccount');
|
||||
return {
|
||||
address: params.address,
|
||||
accountNumber: '1',
|
||||
sequence: '0',
|
||||
pubKey: null,
|
||||
};
|
||||
};
|
||||
|
||||
import type { ApiResponse, AuthStatus, User } from '@sonr.io/com/types';
|
||||
|
||||
/**
|
||||
* Authentication API Client
|
||||
* Integrates with the web/auth WebAuthn authentication system
|
||||
*/
|
||||
export class AuthApiClient {
|
||||
private authUrl: string;
|
||||
private rpcEndpoint: string;
|
||||
|
||||
constructor(authUrl: string, rpcEndpoint: string) {
|
||||
this.authUrl = authUrl;
|
||||
this.rpcEndpoint = rpcEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
async checkAuthStatus(): Promise<AuthStatus> {
|
||||
try {
|
||||
// Check for stored session
|
||||
const session = this.getStoredSession();
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify session is still valid by checking account on chain
|
||||
const account = await getAccount({
|
||||
address: session.address,
|
||||
rpcEndpoint: this.rpcEndpoint,
|
||||
});
|
||||
|
||||
if (account) {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
id: session.userId,
|
||||
address: session.address,
|
||||
username: session.username,
|
||||
did: session.did,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Session invalid, clear it
|
||||
this.clearSession();
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Auth status check failed:', error);
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to authentication app
|
||||
*/
|
||||
redirectToAuth(returnUrl?: string): void {
|
||||
const currentUrl = returnUrl || window.location.href;
|
||||
const authRedirectUrl = `${this.authUrl}/login?returnUrl=${encodeURIComponent(currentUrl)}`;
|
||||
window.location.href = authRedirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication callback
|
||||
*/
|
||||
async handleAuthCallback(params: URLSearchParams): Promise<ApiResponse<User>> {
|
||||
try {
|
||||
const token = params.get('token');
|
||||
const address = params.get('address');
|
||||
const username = params.get('username');
|
||||
const did = params.get('did');
|
||||
|
||||
if (!token || !address) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing authentication parameters',
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the token with the auth service
|
||||
const verified = await this.verifyAuthToken(token, address);
|
||||
|
||||
if (!verified) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid authentication token',
|
||||
};
|
||||
}
|
||||
|
||||
// Store session
|
||||
const user: User = {
|
||||
id: did || address,
|
||||
address,
|
||||
username: username || 'User',
|
||||
did: did || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.storeSession({
|
||||
userId: user.id,
|
||||
address: user.address,
|
||||
username: user.username,
|
||||
did: user.did,
|
||||
token,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: user,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Authentication failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify authentication token with auth service
|
||||
*/
|
||||
private async verifyAuthToken(token: string, address: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.authUrl}/api/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token, address }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.valid === true;
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out the current user
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
try {
|
||||
// Clear local session
|
||||
this.clearSession();
|
||||
|
||||
// Notify auth service
|
||||
await fetch(`${this.authUrl}/api/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user from session
|
||||
*/
|
||||
getCurrentUser(): User | null {
|
||||
const session = this.getStoredSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.userId,
|
||||
address: session.address,
|
||||
username: session.username,
|
||||
did: session.did,
|
||||
createdAt: session.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store session in localStorage
|
||||
*/
|
||||
private storeSession(session: any): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sonr_auth_session', JSON.stringify(session));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored session from localStorage
|
||||
*/
|
||||
private getStoredSession(): any {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionStr = localStorage.getItem('sonr_auth_session');
|
||||
return sessionStr ? JSON.parse(sessionStr) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored session
|
||||
*/
|
||||
private clearSession(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('sonr_auth_session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let authClient: AuthApiClient | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the auth API client
|
||||
*/
|
||||
export function getAuthApiClient(): AuthApiClient {
|
||||
const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3001';
|
||||
const rpcEndpoint = process.env.NEXT_PUBLIC_RPC_ENDPOINT || 'http://localhost:26657';
|
||||
|
||||
if (!authClient) {
|
||||
authClient = new AuthApiClient(authUrl, rpcEndpoint);
|
||||
}
|
||||
|
||||
return authClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication helper functions
|
||||
*/
|
||||
export const authApi = {
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const client = getAuthApiClient();
|
||||
const status = await client.checkAuthStatus();
|
||||
return status.isAuthenticated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
getCurrentUser(): User | null {
|
||||
const client = getAuthApiClient();
|
||||
return client.getCurrentUser();
|
||||
},
|
||||
|
||||
/**
|
||||
* Require authentication (redirect if not authenticated)
|
||||
*/
|
||||
async requireAuth(): Promise<User | null> {
|
||||
const client = getAuthApiClient();
|
||||
const status = await client.checkAuthStatus();
|
||||
|
||||
if (!status.isAuthenticated) {
|
||||
client.redirectToAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
return status.user;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in (redirect to auth app)
|
||||
*/
|
||||
signIn(returnUrl?: string): void {
|
||||
const client = getAuthApiClient();
|
||||
client.redirectToAuth(returnUrl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
const client = getAuthApiClient();
|
||||
await client.signOut();
|
||||
window.location.href = '/';
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle OAuth-style callback
|
||||
*/
|
||||
async handleCallback(): Promise<User | null> {
|
||||
const client = getAuthApiClient();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (!params.has('token')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await client.handleAuthCallback(params);
|
||||
|
||||
if (result.success) {
|
||||
// Clear URL parameters
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook helper for authentication
|
||||
*/
|
||||
export function useAuthCheck() {
|
||||
if (typeof window === 'undefined') {
|
||||
return { loading: true, authenticated: false, user: null };
|
||||
}
|
||||
|
||||
const user = authApi.getCurrentUser();
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
authenticated: !!user,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
export default authApi;
|
||||
@@ -0,0 +1,215 @@
|
||||
import { authApi } from './auth';
|
||||
import { svcApi } from './svc';
|
||||
|
||||
/**
|
||||
* Centralized API configuration and error handling
|
||||
*/
|
||||
export class ApiConfig {
|
||||
private static instance: ApiConfig;
|
||||
|
||||
public rpcEndpoint: string;
|
||||
public restEndpoint: string;
|
||||
public authUrl: string;
|
||||
public chainId: string;
|
||||
public requestInterceptors: Array<(config: any) => any> = [];
|
||||
public responseInterceptors: Array<(response: any) => any> = [];
|
||||
|
||||
private constructor() {
|
||||
// Load from environment variables with defaults
|
||||
this.rpcEndpoint = process.env.NEXT_PUBLIC_RPC_ENDPOINT || 'http://localhost:26657';
|
||||
this.restEndpoint = process.env.NEXT_PUBLIC_REST_ENDPOINT || 'http://localhost:1317';
|
||||
this.authUrl = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3001';
|
||||
this.chainId = process.env.NEXT_PUBLIC_CHAIN_ID || 'sonrtest_1-1';
|
||||
}
|
||||
|
||||
static getInstance(): ApiConfig {
|
||||
if (!ApiConfig.instance) {
|
||||
ApiConfig.instance = new ApiConfig();
|
||||
}
|
||||
return ApiConfig.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure endpoints
|
||||
*/
|
||||
configure(
|
||||
config: Partial<{
|
||||
rpcEndpoint: string;
|
||||
restEndpoint: string;
|
||||
authUrl: string;
|
||||
chainId: string;
|
||||
}>
|
||||
): void {
|
||||
Object.assign(this, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add request interceptor
|
||||
*/
|
||||
addRequestInterceptor(interceptor: (config: any) => any): void {
|
||||
this.requestInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add response interceptor
|
||||
*/
|
||||
addResponseInterceptor(interceptor: (response: any) => any): void {
|
||||
this.responseInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply interceptors to fetch config
|
||||
*/
|
||||
applyRequestInterceptors(config: RequestInit): RequestInit {
|
||||
return this.requestInterceptors.reduce((acc, interceptor) => interceptor(acc), config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply interceptors to response
|
||||
*/
|
||||
applyResponseInterceptors(response: Response): Response {
|
||||
return this.responseInterceptors.reduce((acc, interceptor) => interceptor(acc), response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handling utilities
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
public code: string;
|
||||
public statusCode?: number;
|
||||
public details?: any;
|
||||
|
||||
constructor(message: string, code: string, statusCode?: number, details?: any) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
static fromResponse(response: Response, body?: any): ApiError {
|
||||
const message = body?.message || body?.error || `HTTP ${response.status}`;
|
||||
const code = body?.code || 'API_ERROR';
|
||||
return new ApiError(message, code, response.status, body);
|
||||
}
|
||||
|
||||
static networkError(error: Error): ApiError {
|
||||
return new ApiError('Network request failed', 'NETWORK_ERROR', undefined, {
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
static validationError(message: string, details?: any): ApiError {
|
||||
return new ApiError(message, 'VALIDATION_ERROR', 400, details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced fetch with interceptors and error handling
|
||||
*/
|
||||
export async function apiFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
const config = ApiConfig.getInstance();
|
||||
|
||||
try {
|
||||
// Apply request interceptors
|
||||
const requestConfig = config.applyRequestInterceptors({
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Make the request
|
||||
const response = await fetch(url, requestConfig);
|
||||
|
||||
// Apply response interceptors
|
||||
const processedResponse = config.applyResponseInterceptors(response);
|
||||
|
||||
// Check for errors
|
||||
if (!processedResponse.ok) {
|
||||
const body = await processedResponse.json().catch(() => null);
|
||||
throw ApiError.fromResponse(processedResponse, body);
|
||||
}
|
||||
|
||||
return processedResponse;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof TypeError && error.message === 'Failed to fetch') {
|
||||
throw ApiError.networkError(error);
|
||||
}
|
||||
throw new ApiError(error instanceof Error ? error.message : 'Unknown error', 'UNKNOWN_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup default interceptors
|
||||
*/
|
||||
export function setupDefaultInterceptors(): void {
|
||||
const config = ApiConfig.getInstance();
|
||||
|
||||
// Add authentication header
|
||||
config.addRequestInterceptor((requestConfig) => {
|
||||
const user = authApi.getCurrentUser();
|
||||
if (user?.address) {
|
||||
return {
|
||||
...requestConfig,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
'X-User-Address': user.address,
|
||||
},
|
||||
};
|
||||
}
|
||||
return requestConfig;
|
||||
});
|
||||
|
||||
// Handle authentication errors
|
||||
config.addResponseInterceptor((response) => {
|
||||
if (response.status === 401) {
|
||||
// Clear session and redirect to auth
|
||||
authApi.signIn();
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
// Add request ID for tracking
|
||||
config.addRequestInterceptor((requestConfig) => {
|
||||
return {
|
||||
...requestConfig,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
'X-Request-ID': generateRequestId(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique request ID
|
||||
*/
|
||||
function generateRequestId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* API client factory
|
||||
*/
|
||||
export const api = {
|
||||
auth: authApi,
|
||||
svc: svcApi,
|
||||
config: ApiConfig.getInstance(),
|
||||
fetch: apiFetch,
|
||||
Error: ApiError,
|
||||
setupInterceptors: setupDefaultInterceptors,
|
||||
};
|
||||
|
||||
export type { AuthApiClient } from './auth';
|
||||
// Export individual APIs for convenience
|
||||
export { authApi } from './auth';
|
||||
export type { SvcApiClient } from './svc';
|
||||
export { svcApi } from './svc';
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,318 @@
|
||||
// Temporary stub to fix module resolution - TODO: Replace with actual @sonr.io/es/client import
|
||||
class RpcClient {
|
||||
constructor(_endpoint: string) {
|
||||
console.warn('Using stub implementation for RpcClient');
|
||||
}
|
||||
}
|
||||
|
||||
import type {
|
||||
ApiResponse,
|
||||
DomainVerification,
|
||||
Service,
|
||||
ServiceCapability,
|
||||
} from '@sonr.io/com/types';
|
||||
|
||||
/**
|
||||
* Service Module API Client
|
||||
* Handles all interactions with the x/svc module
|
||||
*/
|
||||
export class SvcApiClient {
|
||||
private rpcClient: RpcClient;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(rpcEndpoint: string) {
|
||||
this.rpcClient = new RpcClient(rpcEndpoint);
|
||||
this.baseUrl = rpcEndpoint.replace('/rpc', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Query module parameters
|
||||
*/
|
||||
async getParams(): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/svc/v1/params`);
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: data.params,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch params',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain verification status
|
||||
*/
|
||||
async getDomainVerification(domain: string): Promise<ApiResponse<DomainVerification>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/svc/v1/domain/${encodeURIComponent(domain)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: data.verification,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch domain verification',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service by ID
|
||||
*/
|
||||
async getService(serviceId: string): Promise<ApiResponse<Service>> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/svc/v1/service/${encodeURIComponent(serviceId)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: data.service,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all services owned by an address
|
||||
*/
|
||||
async getServicesByOwner(owner: string): Promise<ApiResponse<Service[]>> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/svc/v1/services/owner/${encodeURIComponent(owner)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: data.services || [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch services by owner',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services bound to a domain
|
||||
*/
|
||||
async getServicesByDomain(domain: string): Promise<ApiResponse<Service[]>> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/svc/v1/services/domain/${encodeURIComponent(domain)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: data.services || [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch services by domain',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate domain verification
|
||||
* This would typically broadcast a transaction
|
||||
*/
|
||||
async initiateDomainVerification(_domain: string, _signer: any): Promise<ApiResponse<string>> {
|
||||
try {
|
||||
// TODO: Use @sonr.io/es transaction broadcasting
|
||||
// This requires proper message construction with protobuf types
|
||||
// For now, returning a placeholder
|
||||
return {
|
||||
success: false,
|
||||
error: 'Transaction broadcasting not yet implemented',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initiate domain verification',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete domain verification
|
||||
*/
|
||||
async verifyDomain(_domain: string, _signer: any): Promise<ApiResponse<boolean>> {
|
||||
try {
|
||||
// TODO: Use @sonr.io/es transaction broadcasting
|
||||
// This requires proper message construction with protobuf types
|
||||
return {
|
||||
success: false,
|
||||
error: 'Transaction broadcasting not yet implemented',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to verify domain',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new service
|
||||
*/
|
||||
async registerService(
|
||||
_serviceData: {
|
||||
name: string;
|
||||
domain: string;
|
||||
description: string;
|
||||
capabilities: ServiceCapability[];
|
||||
},
|
||||
_signer: any
|
||||
): Promise<ApiResponse<Service>> {
|
||||
try {
|
||||
// TODO: Use @sonr.io/es transaction broadcasting
|
||||
// This requires proper message construction with protobuf types
|
||||
return {
|
||||
success: false,
|
||||
error: 'Transaction broadcasting not yet implemented',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to register service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to check if the API is reachable
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/cosmos/base/tendermint/v1beta1/node_info`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance with configuration
|
||||
let apiClient: SvcApiClient | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the service API client
|
||||
*/
|
||||
export function getSvcApiClient(endpoint?: string): SvcApiClient {
|
||||
const rpcEndpoint = endpoint || process.env.NEXT_PUBLIC_RPC_ENDPOINT || 'http://localhost:26657';
|
||||
|
||||
if (!apiClient || endpoint) {
|
||||
apiClient = new SvcApiClient(rpcEndpoint);
|
||||
}
|
||||
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service API helper functions for common operations
|
||||
*/
|
||||
export const svcApi = {
|
||||
/**
|
||||
* Get all services for the current user
|
||||
*/
|
||||
async getMyServices(ownerAddress: string): Promise<Service[]> {
|
||||
const client = getSvcApiClient();
|
||||
const result = await client.getServicesByOwner(ownerAddress);
|
||||
return result.success ? result.data : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get service details with capabilities
|
||||
*/
|
||||
async getServiceDetails(serviceId: string): Promise<Service | null> {
|
||||
const client = getSvcApiClient();
|
||||
const result = await client.getService(serviceId);
|
||||
return result.success ? result.data : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check domain verification status
|
||||
*/
|
||||
async checkDomainStatus(domain: string): Promise<DomainVerification | null> {
|
||||
const client = getSvcApiClient();
|
||||
const result = await client.getDomainVerification(domain);
|
||||
return result.success ? result.data : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll domain verification status
|
||||
*/
|
||||
async pollDomainVerification(
|
||||
domain: string,
|
||||
intervalMs = 5000,
|
||||
maxAttempts = 60
|
||||
): Promise<DomainVerification | null> {
|
||||
const client = getSvcApiClient();
|
||||
let attempts = 0;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkStatus = async () => {
|
||||
attempts++;
|
||||
const result = await client.getDomainVerification(domain);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const verification = result.data;
|
||||
|
||||
if (
|
||||
verification.status === 'DOMAIN_VERIFICATION_STATUS_VERIFIED' ||
|
||||
verification.status === 'DOMAIN_VERIFICATION_STATUS_FAILED' ||
|
||||
attempts >= maxAttempts
|
||||
) {
|
||||
resolve(verification);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(checkStatus, intervalMs);
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default svcApi;
|
||||
@@ -0,0 +1,444 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Time range presets
|
||||
*/
|
||||
export enum TimeRangePreset {
|
||||
LAST_HOUR = 'last_hour',
|
||||
LAST_24_HOURS = 'last_24_hours',
|
||||
LAST_7_DAYS = 'last_7_days',
|
||||
LAST_30_DAYS = 'last_30_days',
|
||||
LAST_90_DAYS = 'last_90_days',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric aggregation type
|
||||
*/
|
||||
export enum AggregationType {
|
||||
SUM = 'sum',
|
||||
AVG = 'avg',
|
||||
MIN = 'min',
|
||||
MAX = 'max',
|
||||
COUNT = 'count',
|
||||
P50 = 'p50',
|
||||
P95 = 'p95',
|
||||
P99 = 'p99',
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart type for visualization
|
||||
*/
|
||||
export enum ChartType {
|
||||
LINE = 'line',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
PIE = 'pie',
|
||||
DONUT = 'donut',
|
||||
SCATTER = 'scatter',
|
||||
HEATMAP = 'heatmap',
|
||||
METRIC = 'metric',
|
||||
}
|
||||
|
||||
/**
|
||||
* Time range configuration
|
||||
*/
|
||||
export interface TimeRange {
|
||||
preset?: TimeRangePreset;
|
||||
start: string;
|
||||
end: string;
|
||||
timezone?: string;
|
||||
granularity?: 'minute' | 'hour' | 'day' | 'week' | 'month';
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric definition
|
||||
*/
|
||||
export interface Metric {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
change?: {
|
||||
value: number;
|
||||
percentage: number;
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
};
|
||||
sparkline?: number[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time series data point
|
||||
*/
|
||||
export interface TimeSeriesDataPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time series dataset
|
||||
*/
|
||||
export interface TimeSeriesDataset {
|
||||
id: string;
|
||||
name: string;
|
||||
data: TimeSeriesDataPoint[];
|
||||
color?: string;
|
||||
aggregation?: AggregationType;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics query configuration
|
||||
*/
|
||||
export interface AnalyticsQuery {
|
||||
metrics: string[];
|
||||
dimensions?: string[];
|
||||
filters?: Array<{
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'in' | 'nin';
|
||||
value: any;
|
||||
}>;
|
||||
groupBy?: string[];
|
||||
orderBy?: Array<{
|
||||
field: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}>;
|
||||
timeRange: TimeRange;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service analytics metrics
|
||||
*/
|
||||
export interface ServiceAnalytics {
|
||||
serviceId: string;
|
||||
timeRange: TimeRange;
|
||||
summary: {
|
||||
totalRequests: number;
|
||||
uniqueUsers: number;
|
||||
averageLatency: number;
|
||||
errorRate: number;
|
||||
successRate: number;
|
||||
bandwidth: number;
|
||||
};
|
||||
timeSeries: {
|
||||
requests: TimeSeriesDataset;
|
||||
latency: TimeSeriesDataset;
|
||||
errors: TimeSeriesDataset;
|
||||
users: TimeSeriesDataset;
|
||||
};
|
||||
breakdown: {
|
||||
byEndpoint: Array<{ endpoint: string; count: number; percentage: number }>;
|
||||
byStatus: Array<{ status: string; count: number; percentage: number }>;
|
||||
byUser: Array<{ user: string; count: number; percentage: number }>;
|
||||
byCountry: Array<{ country: string; code: string; count: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API usage analytics
|
||||
*/
|
||||
export interface ApiUsageAnalytics {
|
||||
apiKeyId?: string;
|
||||
timeRange: TimeRange;
|
||||
usage: {
|
||||
totalCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
quotaUsed: number;
|
||||
quotaLimit: number;
|
||||
averageResponseTime: number;
|
||||
};
|
||||
endpoints: Array<{
|
||||
path: string;
|
||||
method: string;
|
||||
calls: number;
|
||||
avgLatency: number;
|
||||
errorRate: number;
|
||||
}>;
|
||||
errors: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
count: number;
|
||||
lastOccurred: string;
|
||||
}>;
|
||||
rateLimits: {
|
||||
current: number;
|
||||
limit: number;
|
||||
resetsAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance metrics
|
||||
*/
|
||||
export interface PerformanceMetrics {
|
||||
serviceId: string;
|
||||
timestamp: string;
|
||||
cpu: {
|
||||
usage: number;
|
||||
cores: number;
|
||||
loadAverage: [number, number, number];
|
||||
};
|
||||
memory: {
|
||||
used: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
};
|
||||
latency: {
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
max: number;
|
||||
};
|
||||
throughput: {
|
||||
requestsPerSecond: number;
|
||||
bytesPerSecond: number;
|
||||
};
|
||||
availability: {
|
||||
uptime: number;
|
||||
downtime: number;
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert configuration
|
||||
*/
|
||||
export interface AlertConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
metric: string;
|
||||
condition: {
|
||||
operator: 'gt' | 'lt' | 'gte' | 'lte' | 'eq' | 'neq';
|
||||
threshold: number;
|
||||
duration?: string;
|
||||
};
|
||||
actions: Array<{
|
||||
type: 'email' | 'webhook' | 'slack' | 'discord';
|
||||
config: Record<string, any>;
|
||||
}>;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
lastTriggered?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard configuration
|
||||
*/
|
||||
export interface DashboardConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
widgets: Array<{
|
||||
id: string;
|
||||
type: 'chart' | 'metric' | 'table' | 'list';
|
||||
title: string;
|
||||
config: {
|
||||
chartType?: ChartType;
|
||||
metrics?: string[];
|
||||
query?: AnalyticsQuery;
|
||||
display?: Record<string, any>;
|
||||
};
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
}>;
|
||||
filters?: Record<string, any>;
|
||||
refreshInterval?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation schemas
|
||||
*/
|
||||
export const TimeRangeSchema = z.object({
|
||||
preset: z.nativeEnum(TimeRangePreset).optional(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
granularity: z.enum(['minute', 'hour', 'day', 'week', 'month']).optional(),
|
||||
});
|
||||
|
||||
export const MetricSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
unit: z.string().optional(),
|
||||
change: z
|
||||
.object({
|
||||
value: z.number(),
|
||||
percentage: z.number(),
|
||||
direction: z.enum(['up', 'down', 'stable']),
|
||||
})
|
||||
.optional(),
|
||||
sparkline: z.array(z.number()).optional(),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
export const AnalyticsQuerySchema = z.object({
|
||||
metrics: z.array(z.string()).min(1),
|
||||
dimensions: z.array(z.string()).optional(),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
field: z.string(),
|
||||
operator: z.enum(['eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'in', 'nin']),
|
||||
value: z.any(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
groupBy: z.array(z.string()).optional(),
|
||||
orderBy: z
|
||||
.array(
|
||||
z.object({
|
||||
field: z.string(),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
timeRange: TimeRangeSchema,
|
||||
limit: z.number().min(1).max(1000).optional(),
|
||||
offset: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
export const AlertConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
metric: z.string(),
|
||||
condition: z.object({
|
||||
operator: z.enum(['gt', 'lt', 'gte', 'lte', 'eq', 'neq']),
|
||||
threshold: z.number(),
|
||||
duration: z.string().optional(),
|
||||
}),
|
||||
actions: z.array(
|
||||
z.object({
|
||||
type: z.enum(['email', 'webhook', 'slack', 'discord']),
|
||||
config: z.record(z.any()),
|
||||
})
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
lastTriggered: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
export function formatMetricValue(value: number, unit?: string): string {
|
||||
if (unit === 'bytes') {
|
||||
return formatBytes(value);
|
||||
}
|
||||
if (unit === 'percentage') {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
if (unit === 'ms') {
|
||||
return `${value.toFixed(0)}ms`;
|
||||
}
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(decimals)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function getTimeRangeFromPreset(preset: TimeRangePreset): TimeRange {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
|
||||
switch (preset) {
|
||||
case TimeRangePreset.LAST_HOUR:
|
||||
start.setHours(start.getHours() - 1);
|
||||
break;
|
||||
case TimeRangePreset.LAST_24_HOURS:
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case TimeRangePreset.LAST_7_DAYS:
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case TimeRangePreset.LAST_30_DAYS:
|
||||
start.setDate(start.getDate() - 30);
|
||||
break;
|
||||
case TimeRangePreset.LAST_90_DAYS:
|
||||
start.setDate(start.getDate() - 90);
|
||||
break;
|
||||
default:
|
||||
start.setDate(start.getDate() - 7);
|
||||
}
|
||||
|
||||
return {
|
||||
preset,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMetricChangeDirection(
|
||||
current: number,
|
||||
previous: number
|
||||
): 'up' | 'down' | 'stable' {
|
||||
if (current > previous) return 'up';
|
||||
if (current < previous) return 'down';
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
export function calculatePercentageChange(current: number, previous: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
export function aggregateTimeSeries(
|
||||
data: TimeSeriesDataPoint[],
|
||||
aggregation: AggregationType
|
||||
): number {
|
||||
if (data.length === 0) return 0;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
|
||||
switch (aggregation) {
|
||||
case AggregationType.SUM:
|
||||
return values.reduce((sum, val) => sum + val, 0);
|
||||
case AggregationType.AVG:
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
case AggregationType.MIN:
|
||||
return Math.min(...values);
|
||||
case AggregationType.MAX:
|
||||
return Math.max(...values);
|
||||
case AggregationType.COUNT:
|
||||
return values.length;
|
||||
case AggregationType.P50:
|
||||
return percentile(values, 0.5);
|
||||
case AggregationType.P95:
|
||||
return percentile(values, 0.95);
|
||||
case AggregationType.P99:
|
||||
return percentile(values, 0.99);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.ceil(sorted.length * p) - 1;
|
||||
return sorted[Math.max(0, index)] || 0;
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { z } from 'zod';
|
||||
import type { Service, ServiceDomain } from './service';
|
||||
|
||||
/**
|
||||
* API error codes
|
||||
*/
|
||||
export enum ApiErrorCode {
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
|
||||
AUTHORIZATION_ERROR = 'AUTHORIZATION_ERROR',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
RATE_LIMIT = 'RATE_LIMIT',
|
||||
SERVER_ERROR = 'SERVER_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
CONFLICT = 'CONFLICT',
|
||||
PRECONDITION_FAILED = 'PRECONDITION_FAILED',
|
||||
}
|
||||
|
||||
/**
|
||||
* API response status
|
||||
*/
|
||||
export enum ApiResponseStatus {
|
||||
SUCCESS = 'success',
|
||||
ERROR = 'error',
|
||||
PARTIAL = 'partial',
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API response interface
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: ApiError;
|
||||
status?: ApiResponseStatus;
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API error interface
|
||||
*/
|
||||
export interface ApiError {
|
||||
code: ApiErrorCode | string;
|
||||
message: string;
|
||||
details?: any;
|
||||
statusCode?: number;
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated API response
|
||||
*/
|
||||
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch API response
|
||||
*/
|
||||
export interface BatchResponse<T> extends ApiResponse {
|
||||
results: Array<{
|
||||
id: string;
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: ApiError;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
partial: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication response
|
||||
*/
|
||||
export interface AuthResponse extends ApiResponse {
|
||||
data?: {
|
||||
user: AuthUser;
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated user
|
||||
*/
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
did?: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
createdAt: string;
|
||||
lastLogin?: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication status
|
||||
*/
|
||||
export interface AuthStatus {
|
||||
isAuthenticated: boolean;
|
||||
user: AuthUser | null;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service API responses
|
||||
*/
|
||||
export interface ServiceListResponse extends PaginatedResponse<Service> {
|
||||
filters?: {
|
||||
status?: string[];
|
||||
owner?: string;
|
||||
domain?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceDetailResponse extends ApiResponse<Service> {
|
||||
related?: {
|
||||
domains?: ServiceDomain[];
|
||||
apiKeys?: number;
|
||||
permissions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceCreateResponse extends ApiResponse<Service> {
|
||||
verificationRequired?: boolean;
|
||||
verificationInstructions?: {
|
||||
txtRecord: string;
|
||||
domain: string;
|
||||
ttl: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceUpdateResponse extends ApiResponse<Service> {
|
||||
changes?: {
|
||||
field: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ServiceDeleteResponse extends ApiResponse {
|
||||
data?: {
|
||||
id: string;
|
||||
deletedAt: string;
|
||||
cascaded?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification responses
|
||||
*/
|
||||
export interface DomainVerificationInitResponse extends ApiResponse {
|
||||
data?: {
|
||||
domain: string;
|
||||
challengeToken: string;
|
||||
txtRecord: string;
|
||||
expiresAt: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DomainVerificationStatusResponse extends ApiResponse {
|
||||
data?: {
|
||||
domain: string;
|
||||
status: string;
|
||||
verifiedAt?: string;
|
||||
lastChecked: string;
|
||||
dnsRecordsFound: boolean;
|
||||
expectedRecord: string;
|
||||
actualRecord?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API key responses
|
||||
*/
|
||||
export interface ApiKeyCreateResponse extends ApiResponse {
|
||||
data?: {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string; // Full key only returned on creation
|
||||
prefix: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiKeyListResponse
|
||||
extends PaginatedResponse<{
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
status: string;
|
||||
}> {}
|
||||
|
||||
/**
|
||||
* Analytics responses
|
||||
*/
|
||||
export interface AnalyticsResponse extends ApiResponse {
|
||||
data?: {
|
||||
timeRange: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
metrics: {
|
||||
totalRequests: number;
|
||||
uniqueUsers: number;
|
||||
averageLatency: number;
|
||||
errorRate: number;
|
||||
successRate: number;
|
||||
};
|
||||
timeSeries?: Array<{
|
||||
timestamp: string;
|
||||
requests: number;
|
||||
errors: number;
|
||||
latency: number;
|
||||
}>;
|
||||
breakdown?: {
|
||||
byEndpoint?: Record<string, number>;
|
||||
byStatus?: Record<string, number>;
|
||||
byUser?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket message types
|
||||
*/
|
||||
export interface WebSocketMessage<T = any> {
|
||||
type: 'update' | 'delete' | 'create' | 'error' | 'ping' | 'pong';
|
||||
channel: string;
|
||||
data?: T;
|
||||
timestamp: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request configuration
|
||||
*/
|
||||
export interface ApiRequestConfig {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, any>;
|
||||
body?: any;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
cache?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* API validation schemas
|
||||
*/
|
||||
export const ApiErrorSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.any().optional(),
|
||||
statusCode: z.number().optional(),
|
||||
timestamp: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ApiResponseSchema = z.object({
|
||||
data: z.any().optional(),
|
||||
error: ApiErrorSchema.optional(),
|
||||
status: z.enum(['success', 'error', 'partial']).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const PaginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
|
||||
z.object({
|
||||
data: z.array(itemSchema).optional(),
|
||||
error: ApiErrorSchema.optional(),
|
||||
status: z.enum(['success', 'error', 'partial']).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
requestId: z.string().optional(),
|
||||
pagination: z.object({
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
total: z.number(),
|
||||
totalPages: z.number(),
|
||||
hasNext: z.boolean(),
|
||||
hasPrev: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response type guards
|
||||
*/
|
||||
export function isApiError(response: any): response is ApiError {
|
||||
return response && typeof response.code === 'string' && typeof response.message === 'string';
|
||||
}
|
||||
|
||||
export function isSuccessResponse<T>(response: ApiResponse<T>): boolean {
|
||||
return !response.error && response.status !== 'error';
|
||||
}
|
||||
|
||||
export function isPaginatedResponse<T>(response: any): response is PaginatedResponse<T> {
|
||||
return response && typeof response.pagination === 'object' && Array.isArray(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error factory functions
|
||||
*/
|
||||
export function createApiError(
|
||||
code: ApiErrorCode | string,
|
||||
message: string,
|
||||
details?: any
|
||||
): ApiError {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createNetworkError(message = 'Network request failed'): ApiError {
|
||||
return createApiError(ApiErrorCode.NETWORK_ERROR, message);
|
||||
}
|
||||
|
||||
export function createValidationError(message: string, details?: any): ApiError {
|
||||
return createApiError(ApiErrorCode.VALIDATION_ERROR, message, details);
|
||||
}
|
||||
|
||||
export function createAuthError(message = 'Authentication required'): ApiError {
|
||||
return createApiError(ApiErrorCode.AUTHENTICATION_ERROR, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Response builder functions
|
||||
*/
|
||||
export function createSuccessResponse<T>(data: T, requestId?: string): ApiResponse<T> {
|
||||
return {
|
||||
data,
|
||||
status: ApiResponseStatus.SUCCESS,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createErrorResponse(error: ApiError, requestId?: string): ApiResponse {
|
||||
return {
|
||||
error,
|
||||
status: ApiResponseStatus.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPaginatedResponse<T>(
|
||||
data: T[],
|
||||
page: number,
|
||||
limit: number,
|
||||
total: number
|
||||
): PaginatedResponse<T> {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
return {
|
||||
data,
|
||||
status: ApiResponseStatus.SUCCESS,
|
||||
timestamp: new Date().toISOString(),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Domain verification method
|
||||
*/
|
||||
export enum DomainVerificationMethod {
|
||||
DNS_TXT = 'dns_txt',
|
||||
DNS_CNAME = 'dns_cname',
|
||||
HTTP_FILE = 'http_file',
|
||||
META_TAG = 'meta_tag',
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification status
|
||||
*/
|
||||
export enum DomainVerificationStatus {
|
||||
UNVERIFIED = 'unverified',
|
||||
PENDING = 'pending',
|
||||
VERIFIED = 'verified',
|
||||
FAILED = 'failed',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS record type for verification
|
||||
*/
|
||||
export enum DnsRecordType {
|
||||
TXT = 'TXT',
|
||||
CNAME = 'CNAME',
|
||||
A = 'A',
|
||||
AAAA = 'AAAA',
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification challenge
|
||||
*/
|
||||
export interface DomainChallenge {
|
||||
id: string;
|
||||
domain: string;
|
||||
method: DomainVerificationMethod;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS record for verification
|
||||
*/
|
||||
export interface DnsRecord {
|
||||
type: DnsRecordType;
|
||||
name: string;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification instructions
|
||||
*/
|
||||
export interface DomainVerificationInstructions {
|
||||
method: DomainVerificationMethod;
|
||||
dnsRecords?: DnsRecord[];
|
||||
httpFilePath?: string;
|
||||
httpFileContent?: string;
|
||||
metaTagName?: string;
|
||||
metaTagContent?: string;
|
||||
instructions: string[];
|
||||
estimatedTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification attempt
|
||||
*/
|
||||
export interface DomainVerificationAttempt {
|
||||
id: string;
|
||||
domain: string;
|
||||
timestamp: string;
|
||||
success: boolean;
|
||||
method: DomainVerificationMethod;
|
||||
recordsFound?: DnsRecord[];
|
||||
expectedRecords?: DnsRecord[];
|
||||
error?: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain ownership details
|
||||
*/
|
||||
export interface DomainOwnership {
|
||||
domain: string;
|
||||
owner: string;
|
||||
verifiedAt?: string;
|
||||
expiresAt?: string;
|
||||
autoRenew: boolean;
|
||||
delegatedTo?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain configuration
|
||||
*/
|
||||
export interface DomainConfig {
|
||||
domain: string;
|
||||
subdomains: string[];
|
||||
wildcardEnabled: boolean;
|
||||
sslEnabled: boolean;
|
||||
cors?: {
|
||||
enabled: boolean;
|
||||
origins: string[];
|
||||
methods: string[];
|
||||
headers: string[];
|
||||
};
|
||||
rateLimit?: {
|
||||
enabled: boolean;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
};
|
||||
redirects?: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
statusCode: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification state
|
||||
*/
|
||||
export interface DomainVerification {
|
||||
domain: string;
|
||||
status: DomainVerificationStatus;
|
||||
method?: DomainVerificationMethod;
|
||||
challenge?: DomainChallenge;
|
||||
instructions?: DomainVerificationInstructions;
|
||||
attempts?: DomainVerificationAttempt[];
|
||||
ownership?: DomainOwnership;
|
||||
config?: DomainConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
verifiedAt?: string;
|
||||
lastChecked?: string;
|
||||
nextCheckAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain health status
|
||||
*/
|
||||
export interface DomainHealth {
|
||||
domain: string;
|
||||
status: 'healthy' | 'degraded' | 'offline';
|
||||
sslValid: boolean;
|
||||
sslExpiresAt?: string;
|
||||
dnsResolvable: boolean;
|
||||
httpReachable: boolean;
|
||||
averageResponseTime?: number;
|
||||
lastChecked: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain analytics
|
||||
*/
|
||||
export interface DomainAnalytics {
|
||||
domain: string;
|
||||
timeRange: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
metrics: {
|
||||
totalRequests: number;
|
||||
uniqueVisitors: number;
|
||||
bandwidth: number;
|
||||
cacheHitRate: number;
|
||||
errorRate: number;
|
||||
};
|
||||
topPaths?: Array<{
|
||||
path: string;
|
||||
requests: number;
|
||||
}>;
|
||||
topReferers?: Array<{
|
||||
referer: string;
|
||||
requests: number;
|
||||
}>;
|
||||
geographic?: Array<{
|
||||
country: string;
|
||||
requests: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation schemas
|
||||
*/
|
||||
export const DomainChallengeSchema = z.object({
|
||||
id: z.string(),
|
||||
domain: z.string(),
|
||||
method: z.nativeEnum(DomainVerificationMethod),
|
||||
token: z.string().min(32),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
attempts: z.number().min(0),
|
||||
maxAttempts: z.number().min(1).max(100),
|
||||
});
|
||||
|
||||
export const DnsRecordSchema = z.object({
|
||||
type: z.nativeEnum(DnsRecordType),
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
ttl: z.number().min(60).max(86400).optional(),
|
||||
priority: z.number().min(0).max(65535).optional(),
|
||||
});
|
||||
|
||||
export const DomainVerificationInstructionsSchema = z.object({
|
||||
method: z.nativeEnum(DomainVerificationMethod),
|
||||
dnsRecords: z.array(DnsRecordSchema).optional(),
|
||||
httpFilePath: z.string().optional(),
|
||||
httpFileContent: z.string().optional(),
|
||||
metaTagName: z.string().optional(),
|
||||
metaTagContent: z.string().optional(),
|
||||
instructions: z.array(z.string()),
|
||||
estimatedTime: z.string().optional(),
|
||||
});
|
||||
|
||||
export const DomainOwnershipSchema = z.object({
|
||||
domain: z.string(),
|
||||
owner: z.string(),
|
||||
verifiedAt: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
autoRenew: z.boolean(),
|
||||
delegatedTo: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const DomainConfigSchema = z.object({
|
||||
domain: z.string(),
|
||||
subdomains: z.array(z.string()),
|
||||
wildcardEnabled: z.boolean(),
|
||||
sslEnabled: z.boolean(),
|
||||
cors: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
origins: z.array(z.string()),
|
||||
methods: z.array(z.string()),
|
||||
headers: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
rateLimit: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
requestsPerMinute: z.number().min(1),
|
||||
requestsPerHour: z.number().min(1),
|
||||
})
|
||||
.optional(),
|
||||
redirects: z
|
||||
.array(
|
||||
z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
statusCode: z.number().min(300).max(399),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DomainVerificationSchema = z.object({
|
||||
domain: z.string(),
|
||||
status: z.nativeEnum(DomainVerificationStatus),
|
||||
method: z.nativeEnum(DomainVerificationMethod).optional(),
|
||||
challenge: DomainChallengeSchema.optional(),
|
||||
instructions: DomainVerificationInstructionsSchema.optional(),
|
||||
attempts: z.array(z.any()).optional(), // Simplified for brevity
|
||||
ownership: DomainOwnershipSchema.optional(),
|
||||
config: DomainConfigSchema.optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
verifiedAt: z.string().optional(),
|
||||
lastChecked: z.string().optional(),
|
||||
nextCheckAt: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type guards
|
||||
*/
|
||||
export function isDomainVerified(verification: DomainVerification): boolean {
|
||||
return verification.status === DomainVerificationStatus.VERIFIED;
|
||||
}
|
||||
|
||||
export function isDomainPending(verification: DomainVerification): boolean {
|
||||
return verification.status === DomainVerificationStatus.PENDING;
|
||||
}
|
||||
|
||||
export function isDomainExpired(verification: DomainVerification): boolean {
|
||||
if (verification.status === DomainVerificationStatus.EXPIRED) return true;
|
||||
if (verification.ownership?.expiresAt) {
|
||||
return new Date(verification.ownership.expiresAt) < new Date();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasDnsMethod(verification: DomainVerification): boolean {
|
||||
return (
|
||||
verification.method === DomainVerificationMethod.DNS_TXT ||
|
||||
verification.method === DomainVerificationMethod.DNS_CNAME
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
export function formatDomainStatus(status: DomainVerificationStatus): string {
|
||||
const statusMap: Record<DomainVerificationStatus, string> = {
|
||||
[DomainVerificationStatus.UNVERIFIED]: 'Unverified',
|
||||
[DomainVerificationStatus.PENDING]: 'Pending Verification',
|
||||
[DomainVerificationStatus.VERIFIED]: 'Verified',
|
||||
[DomainVerificationStatus.FAILED]: 'Verification Failed',
|
||||
[DomainVerificationStatus.EXPIRED]: 'Expired',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
export function getDomainStatusColor(status: DomainVerificationStatus): string {
|
||||
const colorMap: Record<DomainVerificationStatus, string> = {
|
||||
[DomainVerificationStatus.VERIFIED]: 'green',
|
||||
[DomainVerificationStatus.PENDING]: 'yellow',
|
||||
[DomainVerificationStatus.FAILED]: 'red',
|
||||
[DomainVerificationStatus.EXPIRED]: 'orange',
|
||||
[DomainVerificationStatus.UNVERIFIED]: 'gray',
|
||||
};
|
||||
return colorMap[status] || 'gray';
|
||||
}
|
||||
|
||||
export function formatDnsRecord(record: DnsRecord): string {
|
||||
return `${record.type} ${record.name} ${record.value}${record.ttl ? ` TTL:${record.ttl}` : ''}`;
|
||||
}
|
||||
|
||||
export function generateTxtRecordValue(token: string, prefix = 'sonr-verification'): string {
|
||||
return `${prefix}=${token}`;
|
||||
}
|
||||
|
||||
export function parseTxtRecordValue(value: string): { prefix: string; token: string } | null {
|
||||
const match = value.match(/^([^=]+)=(.+)$/);
|
||||
if (!match) return null;
|
||||
return { prefix: match[1], token: match[2] };
|
||||
}
|
||||
|
||||
export function estimateVerificationTime(method: DomainVerificationMethod): string {
|
||||
const estimates: Record<DomainVerificationMethod, string> = {
|
||||
[DomainVerificationMethod.DNS_TXT]: '5-60 minutes (DNS propagation)',
|
||||
[DomainVerificationMethod.DNS_CNAME]: '5-60 minutes (DNS propagation)',
|
||||
[DomainVerificationMethod.HTTP_FILE]: '1-2 minutes',
|
||||
[DomainVerificationMethod.META_TAG]: '1-2 minutes',
|
||||
};
|
||||
return estimates[method] || 'Unknown';
|
||||
}
|
||||
|
||||
export function getVerificationMethodName(method: DomainVerificationMethod): string {
|
||||
const names: Record<DomainVerificationMethod, string> = {
|
||||
[DomainVerificationMethod.DNS_TXT]: 'DNS TXT Record',
|
||||
[DomainVerificationMethod.DNS_CNAME]: 'DNS CNAME Record',
|
||||
[DomainVerificationMethod.HTTP_FILE]: 'HTTP File Upload',
|
||||
[DomainVerificationMethod.META_TAG]: 'HTML Meta Tag',
|
||||
};
|
||||
return names[method] || method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation helpers
|
||||
*/
|
||||
export function isValidDomain(domain: string): boolean {
|
||||
const domainRegex = /^[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i;
|
||||
return domainRegex.test(domain);
|
||||
}
|
||||
|
||||
export function isSubdomain(domain: string): boolean {
|
||||
const parts = domain.split('.');
|
||||
return parts.length > 2;
|
||||
}
|
||||
|
||||
export function getBaseDomain(domain: string): string {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length <= 2) return domain;
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
export function getSubdomainPrefix(domain: string): string | null {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length <= 2) return null;
|
||||
return parts.slice(0, -2).join('.');
|
||||
}
|
||||
|
||||
export function normalizeDomain(domain: string): string {
|
||||
return domain
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/$/, '');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Export all type definitions
|
||||
*/
|
||||
|
||||
export type {
|
||||
AggregationType,
|
||||
AlertConfig,
|
||||
AnalyticsQuery,
|
||||
ApiUsageAnalytics,
|
||||
ChartType,
|
||||
DashboardConfig,
|
||||
Metric,
|
||||
PerformanceMetrics,
|
||||
ServiceAnalytics,
|
||||
TimeRange,
|
||||
TimeRangePreset,
|
||||
TimeSeriesDataPoint,
|
||||
TimeSeriesDataset,
|
||||
} from './analytics';
|
||||
// Analytics types
|
||||
export * from './analytics';
|
||||
export type {
|
||||
AnalyticsResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
ApiKeyCreateResponse,
|
||||
ApiKeyListResponse,
|
||||
ApiRequestConfig,
|
||||
ApiResponse,
|
||||
ApiResponseStatus,
|
||||
AuthResponse,
|
||||
AuthStatus,
|
||||
AuthUser,
|
||||
BatchResponse,
|
||||
DomainVerificationInitResponse,
|
||||
DomainVerificationStatusResponse,
|
||||
PaginatedResponse,
|
||||
ServiceCreateResponse,
|
||||
ServiceDeleteResponse,
|
||||
ServiceDetailResponse,
|
||||
ServiceListResponse,
|
||||
ServiceUpdateResponse,
|
||||
WebSocketMessage,
|
||||
} from './api';
|
||||
// API types
|
||||
export * from './api';
|
||||
export type {
|
||||
DnsRecord,
|
||||
DnsRecordType,
|
||||
DomainAnalytics,
|
||||
DomainChallenge,
|
||||
DomainConfig,
|
||||
DomainHealth,
|
||||
DomainOwnership,
|
||||
DomainVerification,
|
||||
DomainVerificationAttempt,
|
||||
DomainVerificationInstructions,
|
||||
DomainVerificationMethod,
|
||||
} from './domain';
|
||||
// Domain types
|
||||
export * from './domain';
|
||||
export type {
|
||||
DomainVerificationStatus,
|
||||
PermissionScope,
|
||||
Service,
|
||||
ServiceApiKey,
|
||||
ServiceCapability,
|
||||
ServiceConfig,
|
||||
ServiceCreateRequest,
|
||||
ServiceDomain,
|
||||
ServiceMetadata,
|
||||
ServicePermission,
|
||||
ServiceStatus,
|
||||
ServiceUpdateRequest,
|
||||
} from './service';
|
||||
// Service types
|
||||
export * from './service';
|
||||
@@ -0,0 +1,351 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Service status enum
|
||||
*/
|
||||
export enum ServiceStatus {
|
||||
ACTIVE = 'active',
|
||||
PENDING = 'pending',
|
||||
SUSPENDED = 'suspended',
|
||||
INACTIVE = 'inactive',
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification status enum
|
||||
*/
|
||||
export enum DomainVerificationStatus {
|
||||
UNVERIFIED = 'unverified',
|
||||
PENDING = 'pending',
|
||||
VERIFIED = 'verified',
|
||||
FAILED = 'failed',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission scope enum
|
||||
*/
|
||||
export enum PermissionScope {
|
||||
DATA = 'data',
|
||||
VAULT = 'vault',
|
||||
PROFILE = 'profile',
|
||||
SERVICE = 'service',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
/**
|
||||
* Service capability interface
|
||||
*/
|
||||
export interface ServiceCapability {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: PermissionScope | string;
|
||||
granted: boolean;
|
||||
expiresAt?: string;
|
||||
constraints?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service permission interface
|
||||
*/
|
||||
export interface ServicePermission {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
granted: boolean;
|
||||
grantedAt?: string;
|
||||
grantedBy?: string;
|
||||
revokedAt?: string;
|
||||
revokedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service API key interface
|
||||
*/
|
||||
export interface ServiceApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key?: string; // Only returned on creation
|
||||
prefix?: string; // Key prefix for identification
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
expiresAt?: string;
|
||||
status: 'active' | 'expired' | 'revoked';
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service metadata interface
|
||||
*/
|
||||
export interface ServiceMetadata {
|
||||
totalRequests?: number;
|
||||
activeUsers?: number;
|
||||
averageLatency?: number;
|
||||
errorRate?: number;
|
||||
uptime?: number;
|
||||
lastHealthCheck?: string;
|
||||
version?: string;
|
||||
environment?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service domain interface
|
||||
*/
|
||||
export interface ServiceDomain {
|
||||
domain: string;
|
||||
verificationStatus: DomainVerificationStatus;
|
||||
verifiedAt?: string;
|
||||
txtRecord?: string;
|
||||
challengeToken?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service configuration interface
|
||||
*/
|
||||
export interface ServiceConfig {
|
||||
webhookUrl?: string;
|
||||
callbackUrl?: string;
|
||||
allowedOrigins?: string[];
|
||||
rateLimits?: {
|
||||
requestsPerMinute?: number;
|
||||
requestsPerHour?: number;
|
||||
requestsPerDay?: number;
|
||||
};
|
||||
features?: {
|
||||
webhooksEnabled?: boolean;
|
||||
analyticsEnabled?: boolean;
|
||||
loggingEnabled?: boolean;
|
||||
};
|
||||
customSettings?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Service interface
|
||||
*/
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
domains?: ServiceDomain[];
|
||||
status: ServiceStatus | string;
|
||||
owner: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
permissions?: ServicePermission[] | string[];
|
||||
capabilities?: ServiceCapability[];
|
||||
apiKeys?: ServiceApiKey[];
|
||||
domainVerificationStatus?: DomainVerificationStatus | string;
|
||||
metadata?: ServiceMetadata;
|
||||
config?: ServiceConfig;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service creation request
|
||||
*/
|
||||
export interface ServiceCreateRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
permissions?: string[];
|
||||
config?: ServiceConfig;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service update request
|
||||
*/
|
||||
export interface ServiceUpdateRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: ServiceStatus;
|
||||
permissions?: string[];
|
||||
config?: ServiceConfig;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service validation schemas using Zod
|
||||
*/
|
||||
|
||||
export const ServiceStatusSchema = z.enum([
|
||||
ServiceStatus.ACTIVE,
|
||||
ServiceStatus.PENDING,
|
||||
ServiceStatus.SUSPENDED,
|
||||
ServiceStatus.INACTIVE,
|
||||
]);
|
||||
|
||||
export const DomainVerificationStatusSchema = z.enum([
|
||||
DomainVerificationStatus.UNVERIFIED,
|
||||
DomainVerificationStatus.PENDING,
|
||||
DomainVerificationStatus.VERIFIED,
|
||||
DomainVerificationStatus.FAILED,
|
||||
DomainVerificationStatus.EXPIRED,
|
||||
]);
|
||||
|
||||
export const ServiceCapabilitySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500),
|
||||
scope: z.string(),
|
||||
granted: z.boolean(),
|
||||
expiresAt: z.string().optional(),
|
||||
constraints: z.record(z.string(), z.any()).optional(),
|
||||
});
|
||||
|
||||
export const ServiceApiKeySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(100),
|
||||
key: z.string().optional(),
|
||||
prefix: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
lastUsed: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
status: z.enum(['active', 'expired', 'revoked']),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ServiceDomainSchema = z.object({
|
||||
domain: z.string(),
|
||||
verificationStatus: DomainVerificationStatusSchema,
|
||||
verifiedAt: z.string().optional(),
|
||||
txtRecord: z.string().optional(),
|
||||
challengeToken: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ServiceConfigSchema = z.object({
|
||||
webhookUrl: z.string().url().optional(),
|
||||
callbackUrl: z.string().url().optional(),
|
||||
allowedOrigins: z.array(z.string()).optional(),
|
||||
rateLimits: z
|
||||
.object({
|
||||
requestsPerMinute: z.number().min(1).max(10000).optional(),
|
||||
requestsPerHour: z.number().min(1).max(100000).optional(),
|
||||
requestsPerDay: z.number().min(1).max(1000000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
features: z
|
||||
.object({
|
||||
webhooksEnabled: z.boolean().optional(),
|
||||
analyticsEnabled: z.boolean().optional(),
|
||||
loggingEnabled: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
customSettings: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const ServiceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(3).max(100),
|
||||
description: z.string().min(10).max(500),
|
||||
domain: z.string(),
|
||||
domains: z.array(ServiceDomainSchema).optional(),
|
||||
status: z.union([ServiceStatusSchema, z.string()]),
|
||||
owner: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
permissions: z.array(z.union([z.string(), z.any()])).optional(),
|
||||
capabilities: z.array(ServiceCapabilitySchema).optional(),
|
||||
apiKeys: z.array(ServiceApiKeySchema).optional(),
|
||||
domainVerificationStatus: z.union([DomainVerificationStatusSchema, z.string()]).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
config: ServiceConfigSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ServiceCreateRequestSchema = z.object({
|
||||
name: z.string().min(3).max(100),
|
||||
description: z.string().min(10).max(500),
|
||||
domain: z.string(),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
config: ServiceConfigSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ServiceUpdateRequestSchema = z.object({
|
||||
name: z.string().min(3).max(100).optional(),
|
||||
description: z.string().min(10).max(500).optional(),
|
||||
status: ServiceStatusSchema.optional(),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
config: ServiceConfigSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type guards
|
||||
*/
|
||||
export function isServiceActive(service: Service): boolean {
|
||||
return service.status === ServiceStatus.ACTIVE || service.status === 'active';
|
||||
}
|
||||
|
||||
export function isDomainVerified(domain: ServiceDomain | string): boolean {
|
||||
if (typeof domain === 'string') {
|
||||
return false;
|
||||
}
|
||||
return domain.verificationStatus === DomainVerificationStatus.VERIFIED;
|
||||
}
|
||||
|
||||
export function hasPermission(service: Service, permission: string): boolean {
|
||||
if (!service.permissions) return false;
|
||||
|
||||
if (Array.isArray(service.permissions)) {
|
||||
return service.permissions.some((p) => {
|
||||
if (typeof p === 'string') {
|
||||
return p === permission;
|
||||
}
|
||||
return p.name === permission && p.granted;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
export function formatServiceStatus(status: ServiceStatus | string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
[ServiceStatus.ACTIVE]: 'Active',
|
||||
[ServiceStatus.PENDING]: 'Pending',
|
||||
[ServiceStatus.SUSPENDED]: 'Suspended',
|
||||
[ServiceStatus.INACTIVE]: 'Inactive',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
export function formatDomainStatus(status: DomainVerificationStatus | string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
[DomainVerificationStatus.UNVERIFIED]: 'Unverified',
|
||||
[DomainVerificationStatus.PENDING]: 'Pending Verification',
|
||||
[DomainVerificationStatus.VERIFIED]: 'Verified',
|
||||
[DomainVerificationStatus.FAILED]: 'Verification Failed',
|
||||
[DomainVerificationStatus.EXPIRED]: 'Expired',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
export function getServiceStatusColor(status: ServiceStatus | string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
[ServiceStatus.ACTIVE]: 'green',
|
||||
[ServiceStatus.PENDING]: 'yellow',
|
||||
[ServiceStatus.SUSPENDED]: 'orange',
|
||||
[ServiceStatus.INACTIVE]: 'gray',
|
||||
};
|
||||
return colorMap[status] || 'gray';
|
||||
}
|
||||
|
||||
export function getDomainStatusColor(status: DomainVerificationStatus | string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
[DomainVerificationStatus.VERIFIED]: 'green',
|
||||
[DomainVerificationStatus.PENDING]: 'yellow',
|
||||
[DomainVerificationStatus.FAILED]: 'red',
|
||||
[DomainVerificationStatus.EXPIRED]: 'orange',
|
||||
[DomainVerificationStatus.UNVERIFIED]: 'gray',
|
||||
};
|
||||
return colorMap[status] || 'gray';
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
// Include @sonr.io/ui components
|
||||
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
prefix: '',
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
// Chart colors
|
||||
chart: {
|
||||
1: 'hsl(var(--chart-1))',
|
||||
2: 'hsl(var(--chart-2))',
|
||||
3: 'hsl(var(--chart-3))',
|
||||
4: 'hsl(var(--chart-4))',
|
||||
5: 'hsl(var(--chart-5))',
|
||||
},
|
||||
// Status colors
|
||||
success: {
|
||||
DEFAULT: 'hsl(142, 76%, 36%)',
|
||||
foreground: 'hsl(355.7, 100%, 97.3%)',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(32, 95%, 44%)',
|
||||
foreground: 'hsl(355.7, 100%, 97.3%)',
|
||||
},
|
||||
info: {
|
||||
DEFAULT: 'hsl(221, 83%, 53%)',
|
||||
foreground: 'hsl(355.7, 100%, 97.3%)',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'fade-out': {
|
||||
'0%': { opacity: '1' },
|
||||
'100%': { opacity: '0' },
|
||||
},
|
||||
'slide-in-from-top': {
|
||||
'0%': { transform: 'translateY(-100%)' },
|
||||
'100%': { transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-out-to-top': {
|
||||
'0%': { transform: 'translateY(0)' },
|
||||
'100%': { transform: 'translateY(-100%)' },
|
||||
},
|
||||
'pulse-subtle': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.8' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'fade-in': 'fade-in 0.5s ease-in-out',
|
||||
'fade-out': 'fade-out 0.5s ease-in-out',
|
||||
'slide-in-from-top': 'slide-in-from-top 0.3s ease-out',
|
||||
'slide-out-to-top': 'slide-out-to-top 0.3s ease-in',
|
||||
'pulse-subtle': 'pulse-subtle 2s ease-in-out infinite',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
||||
mono: ['var(--font-mono)', 'Consolas', 'monospace'],
|
||||
},
|
||||
spacing: {
|
||||
18: '4.5rem',
|
||||
88: '22rem',
|
||||
128: '32rem',
|
||||
},
|
||||
screens: {
|
||||
xs: '475px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
main = ".open-next/worker.js"
|
||||
name = "highway-dash"
|
||||
compatibility_date = "2025-03-25"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[dev]
|
||||
port = 3200
|
||||
|
||||
[assets]
|
||||
directory = ".open-next/assets"
|
||||
binding = "ASSETS"
|
||||
|
||||
# Development environment (default)
|
||||
[env.development]
|
||||
# Add any development-specific environment variables here
|
||||
|
||||
# Production environment
|
||||
[env.production]
|
||||
# Add any production-specific environment variables here
|
||||
|
||||
# Build configuration is handled separately by opennextjs-cloudflare
|
||||
[build]
|
||||
command = "opennextjs-cloudflare build"
|
||||
cwd = "."
|
||||
|
||||
# Analytics and observability
|
||||
[observability]
|
||||
enabled = true
|
||||
Reference in New Issue
Block a user