feat(frontend): add OAuth client management UI

Add a new Developer Settings page at /profile/developer for managing
OAuth clients. Users can now register, view, suspend, activate, and
delete OAuth clients through the UI.

Features:
- Register OAuth clients (public or confidential)
- Configure redirect URIs, homepage, privacy policy, terms of service
- View client credentials (secret shown only once)
- Suspend/activate clients
- Delete clients

The page is accessible from the account menu in the navbar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Swifty
2025-12-09 17:14:48 +01:00
parent 9e67f0bf45
commit 2a4d474ca4
7 changed files with 798 additions and 1 deletions

View File

@@ -0,0 +1,262 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/__legacy__/ui/dialog";
import { LuCopy } from "react-icons/lu";
import { Label } from "@/components/__legacy__/ui/label";
import { Input } from "@/components/__legacy__/ui/input";
import { Textarea } from "@/components/__legacy__/ui/textarea";
import { Button } from "@/components/__legacy__/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { useOAuthClientModals } from "./useOAuthClientModals";
export function OAuthClientModals() {
const {
isCreateOpen,
setIsCreateOpen,
isSecretDialogOpen,
setIsSecretDialogOpen,
formState,
setFormState,
newClientSecret,
isCreating,
handleCreateClient,
handleCopyClientId,
handleCopyClientSecret,
resetForm,
} = useOAuthClientModals();
return (
<div className="mb-4 flex justify-end">
<Dialog
open={isCreateOpen}
onOpenChange={(open) => {
setIsCreateOpen(open);
if (!open) resetForm();
}}
>
<DialogTrigger asChild>
<Button>Register OAuth Client</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Register New OAuth Client</DialogTitle>
<DialogDescription>
Register a new OAuth client to integrate with the AutoGPT
Platform. For confidential clients, the client secret will only be
shown once.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={formState.name}
onChange={(e) =>
setFormState((prev) => ({
...prev,
name: e.target.value,
}))
}
placeholder="My Application"
maxLength={100}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formState.description}
onChange={(e) =>
setFormState((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="A brief description of your application"
maxLength={500}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="redirectUris">
Redirect URIs <span className="text-destructive">*</span>
</Label>
<Textarea
id="redirectUris"
value={formState.redirectUris}
onChange={(e) =>
setFormState((prev) => ({
...prev,
redirectUris: e.target.value,
}))
}
placeholder="https://myapp.com/callback&#10;https://localhost:3000/callback"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Enter one URI per line or separate with commas
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="clientType">Client Type</Label>
<Select
value={formState.clientType}
onValueChange={(value: "public" | "confidential") =>
setFormState((prev) => ({
...prev,
clientType: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Select client type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">
Public (SPA, Mobile apps)
</SelectItem>
<SelectItem value="confidential">
Confidential (Server-side apps)
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Public clients cannot securely store secrets. Confidential
clients receive a client secret.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="homepageUrl">Homepage URL</Label>
<Input
id="homepageUrl"
type="url"
value={formState.homepageUrl}
onChange={(e) =>
setFormState((prev) => ({
...prev,
homepageUrl: e.target.value,
}))
}
placeholder="https://myapp.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="privacyPolicyUrl">Privacy Policy URL</Label>
<Input
id="privacyPolicyUrl"
type="url"
value={formState.privacyPolicyUrl}
onChange={(e) =>
setFormState((prev) => ({
...prev,
privacyPolicyUrl: e.target.value,
}))
}
placeholder="https://myapp.com/privacy"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="termsOfServiceUrl">Terms of Service URL</Label>
<Input
id="termsOfServiceUrl"
type="url"
value={formState.termsOfServiceUrl}
onChange={(e) =>
setFormState((prev) => ({
...prev,
termsOfServiceUrl: e.target.value,
}))
}
placeholder="https://myapp.com/terms"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsCreateOpen(false);
resetForm();
}}
>
Cancel
</Button>
<Button onClick={handleCreateClient} disabled={isCreating}>
{isCreating ? "Creating..." : "Create Client"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isSecretDialogOpen} onOpenChange={setIsSecretDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>OAuth Client Created</DialogTitle>
<DialogDescription>
Please copy your client credentials now. The client secret will
not be shown again!
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Client ID</Label>
<div className="flex items-center space-x-2">
<code className="flex-1 rounded-md bg-secondary p-2 font-mono text-sm">
{newClientSecret?.client_id}
</code>
<Button size="icon" variant="outline" onClick={handleCopyClientId}>
<LuCopy className="h-4 w-4" />
</Button>
</div>
</div>
{newClientSecret?.client_secret && (
<div className="space-y-2">
<Label>Client Secret</Label>
<div className="flex items-center space-x-2">
<code className="flex-1 break-all rounded-md bg-secondary p-2 font-mono text-sm">
{newClientSecret.client_secret}
</code>
<Button
size="icon"
variant="outline"
onClick={handleCopyClientSecret}
>
<LuCopy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-destructive">
This secret will only be shown once. Store it securely!
</p>
</div>
)}
</div>
<DialogFooter>
<Button onClick={() => setIsSecretDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,203 @@
"use client";
import {
getGetOauthClientsListClientsQueryKey,
usePostOauthClientsRegisterClient,
usePostOauthClientsRotateClientSecret,
} from "@/app/api/__generated__/endpoints/oauth-clients/oauth-clients";
import { ClientSecretResponse } from "@/app/api/__generated__/models/clientSecretResponse";
import { RegisterClientRequest } from "@/app/api/__generated__/models/registerClientRequest";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useState } from "react";
type ClientType = "public" | "confidential";
interface ClientFormState {
name: string;
description: string;
redirectUris: string;
clientType: ClientType;
homepageUrl: string;
privacyPolicyUrl: string;
termsOfServiceUrl: string;
}
const initialFormState: ClientFormState = {
name: "",
description: "",
redirectUris: "",
clientType: "public",
homepageUrl: "",
privacyPolicyUrl: "",
termsOfServiceUrl: "",
};
export function useOAuthClientModals() {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSecretDialogOpen, setIsSecretDialogOpen] = useState(false);
const [formState, setFormState] = useState<ClientFormState>(initialFormState);
const [newClientSecret, setNewClientSecret] = useState<ClientSecretResponse | null>(null);
const queryClient = getQueryClient();
const { toast } = useToast();
const { mutateAsync: registerClient, isPending: isCreating } =
usePostOauthClientsRegisterClient({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthClientsListClientsQueryKey(),
});
},
},
});
const { mutateAsync: rotateSecret, isPending: isRotating } =
usePostOauthClientsRotateClientSecret({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthClientsListClientsQueryKey(),
});
},
},
});
function resetForm() {
setFormState(initialFormState);
}
async function handleCreateClient() {
// Parse redirect URIs (comma or newline separated)
const redirectUris = formState.redirectUris
.split(/[,\n]/)
.map((uri) => uri.trim())
.filter((uri) => uri.length > 0);
if (redirectUris.length === 0) {
toast({
title: "Error",
description: "At least one redirect URI is required",
variant: "destructive",
});
return;
}
if (!formState.name.trim()) {
toast({
title: "Error",
description: "Client name is required",
variant: "destructive",
});
return;
}
try {
const requestData: RegisterClientRequest = {
name: formState.name.trim(),
redirect_uris: redirectUris,
client_type: formState.clientType,
};
if (formState.description.trim()) {
requestData.description = formState.description.trim();
}
if (formState.homepageUrl.trim()) {
requestData.homepage_url = formState.homepageUrl.trim();
}
if (formState.privacyPolicyUrl.trim()) {
requestData.privacy_policy_url = formState.privacyPolicyUrl.trim();
}
if (formState.termsOfServiceUrl.trim()) {
requestData.terms_of_service_url = formState.termsOfServiceUrl.trim();
}
const response = await registerClient({
data: requestData,
});
if (response.status === 200) {
const secretData = response.data as ClientSecretResponse;
setNewClientSecret(secretData);
setIsCreateOpen(false);
setIsSecretDialogOpen(true);
resetForm();
toast({
title: "Success",
description: "OAuth client created successfully",
});
}
} catch {
toast({
title: "Error",
description: "Failed to create OAuth client",
variant: "destructive",
});
}
}
async function handleRotateSecret(clientId: string) {
try {
const response = await rotateSecret({ clientId });
if (response.status === 200) {
const secretData = response.data as ClientSecretResponse;
setNewClientSecret(secretData);
setIsSecretDialogOpen(true);
toast({
title: "Success",
description: "Client secret rotated successfully",
});
}
} catch {
toast({
title: "Error",
description: "Failed to rotate client secret",
variant: "destructive",
});
}
}
function handleCopyClientId() {
if (newClientSecret?.client_id) {
navigator.clipboard.writeText(newClientSecret.client_id);
toast({
title: "Copied",
description: "Client ID copied to clipboard",
});
}
}
function handleCopyClientSecret() {
if (newClientSecret?.client_secret) {
navigator.clipboard.writeText(newClientSecret.client_secret);
toast({
title: "Copied",
description: "Client secret copied to clipboard",
});
}
}
function handleSecretDialogChange(open: boolean) {
setIsSecretDialogOpen(open);
if (!open) {
setNewClientSecret(null);
}
}
return {
isCreateOpen,
setIsCreateOpen,
isSecretDialogOpen,
setIsSecretDialogOpen: handleSecretDialogChange,
formState,
setFormState,
newClientSecret,
isCreating,
isRotating,
handleCreateClient,
handleRotateSecret,
handleCopyClientId,
handleCopyClientSecret,
resetForm,
};
}

View File

@@ -0,0 +1,146 @@
"use client";
import { Loader2, MoreVertical } from "lucide-react";
import { Button } from "@/components/__legacy__/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Badge } from "@/components/__legacy__/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/__legacy__/ui/dropdown-menu";
import { useOAuthClientSection } from "./useOAuthClientSection";
export function OAuthClientSection() {
const {
oauthClients,
isLoading,
isDeleting,
isSuspending,
isActivating,
handleDeleteClient,
handleSuspendClient,
handleActivateClient,
} = useOAuthClientSection();
const isActionPending = isDeleting || isSuspending || isActivating;
return (
<>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : oauthClients && oauthClients.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Client ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{oauthClients.map((client) => (
<TableRow key={client.id} data-testid="oauth-client-row">
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{client.name}</span>
{client.description && (
<span className="text-xs text-muted-foreground">
{client.description}
</span>
)}
</div>
</TableCell>
<TableCell data-testid="oauth-client-id">
<div className="rounded-md border p-1 px-2 font-mono text-xs">
{client.client_id}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{client.client_type === "confidential"
? "Confidential"
: "Public"}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
client.status === "ACTIVE" ? "default" : "destructive"
}
className={
client.status === "ACTIVE"
? "border-green-600 bg-green-100 text-green-800"
: "border-red-600 bg-red-100 text-red-800"
}
>
{client.status}
</Badge>
</TableCell>
<TableCell>
{new Date(client.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="oauth-client-actions"
variant="ghost"
size="sm"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{client.status === "ACTIVE" ? (
<DropdownMenuItem
onClick={() => handleSuspendClient(client.client_id)}
disabled={isActionPending}
>
Suspend
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => handleActivateClient(client.client_id)}
disabled={isActionPending}
>
Activate
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteClient(client.client_id)}
disabled={isActionPending}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="py-8 text-center text-muted-foreground">
No OAuth clients registered yet. Create one to get started.
</div>
)}
</>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import {
getGetOauthClientsListClientsQueryKey,
useDeleteOauthClientsDeleteClient,
useGetOauthClientsListClients,
usePostOauthClientsActivateClient,
usePostOauthClientsSuspendClient,
} from "@/app/api/__generated__/endpoints/oauth-clients/oauth-clients";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";
export function useOAuthClientSection() {
const queryClient = getQueryClient();
const { toast } = useToast();
const { data: oauthClients, isLoading } = useGetOauthClientsListClients({
query: {
select: (res) => {
if (res.status !== 200) return undefined;
return res.data;
},
},
});
const { mutateAsync: deleteClient, isPending: isDeleting } =
useDeleteOauthClientsDeleteClient({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthClientsListClientsQueryKey(),
});
},
},
});
const { mutateAsync: suspendClient, isPending: isSuspending } =
usePostOauthClientsSuspendClient({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthClientsListClientsQueryKey(),
});
},
},
});
const { mutateAsync: activateClient, isPending: isActivating } =
usePostOauthClientsActivateClient({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthClientsListClientsQueryKey(),
});
},
},
});
async function handleDeleteClient(clientId: string) {
try {
await deleteClient({ clientId });
toast({
title: "Success",
description: "OAuth client deleted successfully",
});
} catch {
toast({
title: "Error",
description: "Failed to delete OAuth client",
variant: "destructive",
});
}
}
async function handleSuspendClient(clientId: string) {
try {
await suspendClient({ clientId });
toast({
title: "Success",
description: "OAuth client suspended successfully",
});
} catch {
toast({
title: "Error",
description: "Failed to suspend OAuth client",
variant: "destructive",
});
}
}
async function handleActivateClient(clientId: string) {
try {
await activateClient({ clientId });
toast({
title: "Success",
description: "OAuth client activated successfully",
});
} catch {
toast({
title: "Error",
description: "Failed to activate OAuth client",
variant: "destructive",
});
}
}
return {
oauthClients,
isLoading,
isDeleting,
isSuspending,
isActivating,
handleDeleteClient,
handleSuspendClient,
handleActivateClient,
};
}

View File

@@ -0,0 +1,37 @@
import { Metadata } from "next/types";
import { OAuthClientSection } from "@/app/(platform)/profile/(user)/developer/components/OAuthClientSection/OAuthClientSection";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/__legacy__/ui/card";
import { OAuthClientModals } from "./components/OAuthClientModals/OAuthClientModals";
export const metadata: Metadata = {
title: "Developer Settings - AutoGPT Platform",
};
function DeveloperPage() {
return (
<div className="w-full pr-4 pt-24 md:pt-0">
<Card>
<CardHeader>
<CardTitle>OAuth Applications</CardTitle>
<CardDescription>
Register and manage OAuth clients to integrate third-party
applications with the AutoGPT Platform. OAuth clients allow external
applications to access AutoGPT APIs on behalf of users.
</CardDescription>
</CardHeader>
<CardContent>
<OAuthClientModals />
<OAuthClientSection />
</CardContent>
</Card>
</div>
);
}
export default DeveloperPage;

View File

@@ -883,6 +883,30 @@ export const IconUploadCloud = createIcon((props) => (
</svg>
));
/**
* Code icon component for developer settings.
*
* @component IconCode
* @param {IconProps} props - The props object containing additional attributes and event handlers for the icon.
* @returns {JSX.Element} - The code icon.
*/
export const IconCode = createIcon((props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Code Icon"
{...props}
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
));
/**
* Chevron up icon component.
*
@@ -1838,6 +1862,7 @@ export enum IconType {
AutoGPTLogo,
Sliders,
Chat,
Code,
}
export function getIconForSocial(

View File

@@ -1,5 +1,6 @@
import {
IconBuilder,
IconCode,
IconEdit,
IconLibrary,
IconLogOut,
@@ -130,10 +131,15 @@ export function getAccountMenuItems(userRole?: string): MenuItemGroup[] {
});
}
// Add settings and logout
// Add developer settings and settings
baseMenuItems.push(
{
items: [
{
icon: IconType.Code,
text: "Developer",
href: "/profile/developer",
},
{
icon: IconType.Settings,
text: "Settings",
@@ -177,6 +183,8 @@ export function getAccountMenuOptionIcon(icon: IconType) {
return <IconSliders className={iconClass} />;
case IconType.Chat:
return <ChatsIcon className={iconClass} />;
case IconType.Code:
return <IconCode className={iconClass} />;
default:
return <IconRefresh className={iconClass} />;
}