mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-04 02:11:40 +00:00
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user