* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
+325
View File
@@ -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>
);
}
+262
View File
@@ -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>
);
}
+325
View File
@@ -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>
);
}