Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/app/control/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"dependencies": {
"@auth/core": "^0.40.0",
"@auth/prisma-adapter": "^2.10.0",
"@coinbase/x402": "^0.6.4",
"@hookform/resolvers": "^5.2.1",
"@icons-pack/react-simple-icons": "^13.7.0",
"@merit-systems/sdk": "0.0.8",
Expand Down Expand Up @@ -64,6 +65,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/render": "^1.2.3",
"@shikijs/core": "^3.12.2",
"@shikijs/engine-javascript": "^3.12.2",
Expand All @@ -87,7 +89,6 @@
"autonumeric": "^4.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"@coinbase/x402": "^0.6.4",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
Expand Down Expand Up @@ -136,9 +137,9 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@faker-js/faker": "^9.9.0",
"@next/eslint-plugin-next": "^15.5.3",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@next/eslint-plugin-next": "^15.5.3",
"@types/node": "^20",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { Loader2 } from 'lucide-react';
import { Loader2, Trash2 } from 'lucide-react';

import { format } from 'date-fns';

Expand All @@ -14,11 +14,25 @@ import {
TableCell,
TableEmpty,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';

import { KeyStatus, LoadingKeyStatus } from './status';

import { Skeleton } from '@/components/ui/skeleton';
import { UserAvatar } from '@/components/utils/user-avatar';
import { api } from '@/trpc/client';
import { useState } from 'react';
import { toast } from 'sonner';

interface Key {
id: string;
Expand Down Expand Up @@ -50,7 +64,7 @@ export const KeysTable: React.FC<Props> = ({ keys, pagination }) => {
{keys.length > 0 ? (
<KeyRows keys={keys} />
) : (
<TableEmpty colSpan={4}>No keys found</TableEmpty>
<TableEmpty colSpan={6}>No keys found</TableEmpty>
)}
</BaseKeysTable>
);
Expand All @@ -70,6 +84,23 @@ const KeyRows = ({ keys }: { keys: Key[] }) => {
};

const KeyRow = ({ apiKey }: { apiKey: Key }) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const deleteApiKey = api.user.apiKeys.delete.useMutation({
onSuccess: () => {
toast.success('API key revoked successfully');
void utils.user.apiKeys.list.invalidate();
},
onError: error => {
toast.error(error.message || 'Failed to revoke API key');
},
});

const handleRevoke = () => {
deleteApiKey.mutate(apiKey.id);
setIsOpen(false);
};

return (
<TableRow key={apiKey.id}>
<TableCell className="pl-4 font-bold">{apiKey.name}</TableCell>
Expand All @@ -89,6 +120,48 @@ const KeyRow = ({ apiKey }: { apiKey: Key }) => {
<TableCell>
<KeyStatus isArchived={apiKey.isArchived} />
</TableCell>
<TableCell>
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
disabled={apiKey.isArchived}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Revoke API key</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API Key</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke this API key? This action cannot
be undone. Any applications using this key will no longer be
able to authenticate.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
disabled={deleteApiKey.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteApiKey.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Revoking...
</>
) : (
'Revoke'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
);
};
Expand All @@ -111,6 +184,9 @@ const LoadingKeyRow = () => {
<TableCell>
<LoadingKeyStatus />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8" />
</TableCell>
</TableRow>
);
};
Expand All @@ -131,6 +207,7 @@ const BaseKeysTable = ({ children, pagination }: BaseKeysTableProps) => {
<TableHead>Last Used</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[30px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>{children}</TableBody>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { BrainCircuit, User } from 'lucide-react';
import { BrainCircuit, Info, Key, User } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';

interface Props {
scopes: string[];
Expand All @@ -20,11 +25,24 @@ const Scope = ({ scope }: { scope: string }) => {
if (!data) {
return null;
}

return (
<li className="flex items-center gap-2 text-sm">
<li
className={`flex items-center gap-2 text-sm ${data.level === 'warn' ? 'text-yellow-500' : ''}`}
>
<data.icon className="size-4" />
{data.name}
{data.description && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground cursor-pointer">
<Info className="size-4" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{data.description}</p>
</TooltipContent>
</Tooltip>
)}
</li>
);
};
Expand All @@ -33,9 +51,22 @@ const scopeData = {
'llm:invoke': {
name: 'Make AI requests',
icon: BrainCircuit,
level: 'info',
description:
'You are allowing this app to make AI requests on your behalf.',
},
offline_access: {
name: 'Connect your user profile',
icon: User,
level: 'info',
description:
'You are allowing this app to connect your user profile to your account.',
},
'api_key:create': {
name: 'Create API keys',
icon: Key,
level: 'warn',
description:
'You are allowing this app to create a long lived access token, which can be revoked at any time in your Echo dashboard.',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getApp } from '@/services/db/apps/get';
import { createAppMembership } from '@/services/db/apps/membership';

import { issueOAuthToken } from '@/services/db/auth/oauth-token';
import { createApiKey } from '@/services/db/api-keys';

import type { TokenMetadata } from '@/types/token-metadata';

Expand Down Expand Up @@ -178,13 +179,58 @@ export async function handleIssueToken(
}
}

/* 🔟 Check if api_key:create scope is present */
const scopes = scope.split(' ');
const shouldCreateApiKey = scopes.includes('api_key:create');

const { session, refreshToken } = await issueOAuthToken({
userId: user.id,
appId: app.id,
scope,
metadata,
});

if (shouldCreateApiKey) {
logger.emit({
severityText: 'INFO',
body: 'Creating API key for user with api_key:create scope',
attributes: {
userId: user.id,
echoAppId: app.id,
function: 'handleInitialTokenIssuance',
},
});

/* Generate an API key instead of a temporary JWT token */
const apiKey = await createApiKey(user.id, {
echoAppId: app.id,
name: 'OAuth Generated API Key',
});

logger.emit({
severityText: 'INFO',
body: 'API key generated for OAuth flow',
attributes: {
userId: user.id,
echoAppId: app.id,
apiKeyId: apiKey.id,
function: 'handleInitialTokenIssuance',
},
});

/* Return API key as access token with a very long expiration (100 years) */
return tokenResponse({
accessToken: {
access_token: apiKey.key,
scope,
access_token_expiry: new Date(
Date.now() + 100 * 365 * 24 * 60 * 60 * 1000
), // 100 years
},
refreshToken,
});
}

const accessToken = await createEchoAccessJwt({
user_id: user.id,
app_id: app.id,
Expand Down
46 changes: 28 additions & 18 deletions packages/app/control/src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ function AlertDialog({
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}

// function AlertDialogTrigger({
// ...props
// }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
// return (
// <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
// );
// }
const AlertDialogTrigger = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<AlertDialogPrimitive.Trigger
ref={ref}
data-slot="alert-dialog-trigger"
{...props}
/>
);
});
AlertDialogTrigger.displayName = 'AlertDialogTrigger';

function AlertDialogPortal({
...props
Expand Down Expand Up @@ -130,24 +136,28 @@ function AlertDialogAction({
);
}

// function AlertDialogCancel({
// className,
// ...props
// }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
// return (
// <AlertDialogPrimitive.Cancel
// className={cn(buttonVariants({ variant: 'outline' }), className)}
// {...props}
// />
// );
// }
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
);
});
AlertDialogCancel.displayName = 'AlertDialogCancel';

export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
Loading
Loading