This commit is contained in:
2026-02-24 19:31:30 +01:00
parent 28898fb081
commit 1bb9241116
16 changed files with 878 additions and 50 deletions

View File

@@ -11,8 +11,10 @@
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@better-auth/oauth-provider": "^1.4.19",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/instrument-serif": "^5.2.8",
"@modelcontextprotocol/sdk": "^1.27.0",
"@tailwindcss/vite": "^4.1.8",
"@tanstack/react-form": "^1.23.5",
"@tanstack/react-query": "^5.80.6",

View File

@@ -1,6 +1,7 @@
import { createAuthClient } from "better-auth/react";
import { anonymousClient } from "better-auth/client/plugins";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
export const authClient = createAuthClient({
plugins: [anonymousClient()],
plugins: [anonymousClient(), oauthProviderClient()],
});

View File

@@ -0,0 +1,7 @@
import { auth } from "@zendegi/auth";
import { createAuthClient } from "better-auth/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
export const serverClient = createAuthClient({
plugins: [oauthProviderResourceClient(auth)],
});

View File

@@ -14,9 +14,15 @@ import { Route as TimelineRouteImport } from './routes/timeline'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DemoRouteImport } from './routes/demo'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as ConsentRouteImport } from './routes/consent'
import { Route as IndexRouteImport } from './routes/index'
import { Route as TimelineTimelineIdRouteImport } from './routes/timeline.$timelineId'
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.well-known]/openid-configuration'
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource'
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.well-known]/oauth-authorization-server'
import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.well-known]/oauth-authorization-server.api.auth'
const TimelinesRoute = TimelinesRouteImport.update({
id: '/timelines',
@@ -43,6 +49,11 @@ const DashboardRoute = DashboardRouteImport.update({
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const ConsentRoute = ConsentRouteImport.update({
id: '/consent',
path: '/consent',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -53,84 +64,154 @@ const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({
path: '/$timelineId',
getParentRoute: () => TimelineRoute,
} as any)
const DotwellKnownOpenidConfigurationRoute =
DotwellKnownOpenidConfigurationRouteImport.update({
id: '/.well-known/openid-configuration',
path: '/.well-known/openid-configuration',
getParentRoute: () => rootRouteImport,
} as any)
const DotwellKnownOauthProtectedResourceRoute =
DotwellKnownOauthProtectedResourceRouteImport.update({
id: '/.well-known/oauth-protected-resource',
path: '/.well-known/oauth-protected-resource',
getParentRoute: () => rootRouteImport,
} as any)
const DotwellKnownOauthAuthorizationServerRoute =
DotwellKnownOauthAuthorizationServerRouteImport.update({
id: '/.well-known/oauth-authorization-server',
path: '/.well-known/oauth-authorization-server',
getParentRoute: () => rootRouteImport,
} as any)
const ApiMcpSplatRoute = ApiMcpSplatRouteImport.update({
id: '/api/mcp/$',
path: '/api/mcp/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
id: '/api/auth',
path: '/api/auth',
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/consent': typeof ConsentRoute
'/dashboard': typeof DashboardRoute
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/timeline': typeof TimelineRouteWithChildren
'/timelines': typeof TimelinesRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/mcp/$': typeof ApiMcpSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/consent': typeof ConsentRoute
'/dashboard': typeof DashboardRoute
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/timeline': typeof TimelineRouteWithChildren
'/timelines': typeof TimelinesRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/mcp/$': typeof ApiMcpSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/consent': typeof ConsentRoute
'/dashboard': typeof DashboardRoute
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/timeline': typeof TimelineRouteWithChildren
'/timelines': typeof TimelinesRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/mcp/$': typeof ApiMcpSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/consent'
| '/dashboard'
| '/demo'
| '/login'
| '/timeline'
| '/timelines'
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/timeline/$timelineId'
| '/api/auth/$'
| '/api/mcp/$'
| '/.well-known/oauth-authorization-server/api/auth'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/consent'
| '/dashboard'
| '/demo'
| '/login'
| '/timeline'
| '/timelines'
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/timeline/$timelineId'
| '/api/auth/$'
| '/api/mcp/$'
| '/.well-known/oauth-authorization-server/api/auth'
id:
| '__root__'
| '/'
| '/consent'
| '/dashboard'
| '/demo'
| '/login'
| '/timeline'
| '/timelines'
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/timeline/$timelineId'
| '/api/auth/$'
| '/api/mcp/$'
| '/.well-known/oauth-authorization-server/api/auth'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConsentRoute: typeof ConsentRoute
DashboardRoute: typeof DashboardRoute
DemoRoute: typeof DemoRoute
LoginRoute: typeof LoginRoute
TimelineRoute: typeof TimelineRouteWithChildren
TimelinesRoute: typeof TimelinesRoute
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiMcpSplatRoute: typeof ApiMcpSplatRoute
}
declare module '@tanstack/react-router' {
@@ -170,6 +251,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/consent': {
id: '/consent'
path: '/consent'
fullPath: '/consent'
preLoaderRoute: typeof ConsentRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -184,6 +272,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TimelineTimelineIdRouteImport
parentRoute: typeof TimelineRoute
}
'/.well-known/openid-configuration': {
id: '/.well-known/openid-configuration'
path: '/.well-known/openid-configuration'
fullPath: '/.well-known/openid-configuration'
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport
parentRoute: typeof rootRouteImport
}
'/.well-known/oauth-protected-resource': {
id: '/.well-known/oauth-protected-resource'
path: '/.well-known/oauth-protected-resource'
fullPath: '/.well-known/oauth-protected-resource'
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceRouteImport
parentRoute: typeof rootRouteImport
}
'/.well-known/oauth-authorization-server': {
id: '/.well-known/oauth-authorization-server'
path: '/.well-known/oauth-authorization-server'
fullPath: '/.well-known/oauth-authorization-server'
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
parentRoute: typeof rootRouteImport
}
'/api/mcp/$': {
id: '/api/mcp/$'
path: '/api/mcp/$'
fullPath: '/api/mcp/$'
preLoaderRoute: typeof ApiMcpSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
@@ -191,6 +307,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/.well-known/oauth-authorization-server/api/auth': {
id: '/.well-known/oauth-authorization-server/api/auth'
path: '/api/auth'
fullPath: '/.well-known/oauth-authorization-server/api/auth'
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute
}
}
}
@@ -206,14 +329,36 @@ const TimelineRouteWithChildren = TimelineRoute._addFileChildren(
TimelineRouteChildren,
)
interface DotwellKnownOauthAuthorizationServerRouteChildren {
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
}
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
{
DotwellKnownOauthAuthorizationServerApiAuthRoute:
DotwellKnownOauthAuthorizationServerApiAuthRoute,
}
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
DotwellKnownOauthAuthorizationServerRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConsentRoute: ConsentRoute,
DashboardRoute: DashboardRoute,
DemoRoute: DemoRoute,
LoginRoute: LoginRoute,
TimelineRoute: TimelineRouteWithChildren,
TimelinesRoute: TimelinesRoute,
DotwellKnownOauthAuthorizationServerRoute:
DotwellKnownOauthAuthorizationServerRouteWithChildren,
DotwellKnownOauthProtectedResourceRoute:
DotwellKnownOauthProtectedResourceRoute,
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiMcpSplatRoute: ApiMcpSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@zendegi/auth";
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
const handler = oauthProviderAuthServerMetadata(auth);
export const Route = createFileRoute(
"/.well-known/oauth-authorization-server/api/auth"
)({
server: {
handlers: {
GET: ({ request }) => handler(request),
},
},
});

View File

@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@zendegi/auth";
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
const handler = oauthProviderAuthServerMetadata(auth);
export const Route = createFileRoute("/.well-known/oauth-authorization-server")(
{
server: {
handlers: {
GET: ({ request }) => handler(request),
},
},
},
);

View File

@@ -0,0 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "@zendegi/env/server";
export const Route = createFileRoute("/.well-known/oauth-protected-resource")({
server: {
handlers: {
GET: () => {
const baseUrl = env.BETTER_AUTH_URL.replace(/\/api\/auth$/, "");
return new Response(
JSON.stringify({
resource: baseUrl,
authorization_servers: [`${baseUrl}/api/auth`],
bearer_methods_supported: ["header"],
}),
{
headers: {
"Content-Type": "application/json",
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
},
);
},
},
},
});

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@zendegi/auth";
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
const handler = oauthProviderOpenIdConfigMetadata(auth);
export const Route = createFileRoute("/.well-known/openid-configuration")({
server: {
handlers: {
GET: ({ request }) => handler(request),
},
},
});

View File

@@ -0,0 +1,316 @@
import { createFileRoute } from "@tanstack/react-router";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { mcpHandler } from "@better-auth/oauth-provider";
import { env } from "@zendegi/env/server";
import { db } from "@zendegi/db";
import {
timeline,
timelineGroup,
timelineItem,
} from "@zendegi/db/schema/timeline";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
function createMcpServer(userId: string) {
const server = new McpServer({
name: "zendegi",
version: "1.0.0",
});
// --- Read tools ---
server.registerTool(
"list_timelines",
{
description: "List the authenticated user's timelines",
},
async () => {
const timelines = await db.query.timeline.findMany({
where: eq(timeline.ownerId, userId),
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
return {
content: [{ type: "text" as const, text: JSON.stringify(timelines) }],
};
}
);
server.registerTool(
"get_timeline",
{
description: "Get a timeline by ID, including its groups and items",
inputSchema: { id: z.string().uuid().describe("Timeline ID") },
},
async ({ id }) => {
const result = await db.query.timeline.findFirst({
where: and(eq(timeline.id, id), eq(timeline.ownerId, userId)),
with: {
groups: {
orderBy: (g, { asc }) => [asc(g.sortOrder)],
with: {
items: {
orderBy: (i, { asc }) => [asc(i.start)],
},
},
},
},
});
if (!result) {
return {
content: [{ type: "text" as const, text: "Timeline not found" }],
isError: true,
};
}
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
}
);
// --- Create tools ---
server.registerTool(
"create_timeline",
{
description: "Create a new timeline",
inputSchema: { title: z.string().min(1).describe("Timeline title") },
},
async ({ title }) => {
const [created] = await db
.insert(timeline)
.values({ title, ownerId: userId })
.returning();
return {
content: [{ type: "text" as const, text: JSON.stringify(created) }],
};
}
);
server.registerTool(
"create_group",
{
description: "Create a group in a timeline",
inputSchema: {
title: z.string().min(1).describe("Group title"),
timelineId: z.string().uuid().describe("Parent timeline ID"),
},
},
async ({ title, timelineId }) => {
// Verify ownership
const t = await db.query.timeline.findFirst({
where: and(eq(timeline.id, timelineId), eq(timeline.ownerId, userId)),
});
if (!t) {
return {
content: [{ type: "text" as const, text: "Timeline not found" }],
isError: true,
};
}
const [created] = await db
.insert(timelineGroup)
.values({ title, timelineId })
.returning();
return {
content: [{ type: "text" as const, text: JSON.stringify(created) }],
};
}
);
server.registerTool(
"create_item",
{
description: "Create an item in a group",
inputSchema: {
title: z.string().min(1).describe("Item title"),
description: z.string().default("").describe("Item description"),
start: z.string().describe("Start date (ISO 8601)"),
end: z.string().optional().describe("End date (ISO 8601, optional)"),
timelineGroupId: z.string().uuid().describe("Parent group ID"),
},
},
async ({ title, description, start, end, timelineGroupId }) => {
// Verify ownership through the group's timeline
const group = await db.query.timelineGroup.findFirst({
where: eq(timelineGroup.id, timelineGroupId),
with: { timeline: true },
});
if (!group || group.timeline.ownerId !== userId) {
return {
content: [{ type: "text" as const, text: "Group not found" }],
isError: true,
};
}
const [created] = await db
.insert(timelineItem)
.values({
title,
description,
start: new Date(start),
end: end ? new Date(end) : undefined,
timelineGroupId,
})
.returning();
return {
content: [{ type: "text" as const, text: JSON.stringify(created) }],
};
}
);
// --- Update tools ---
server.registerTool(
"update_item",
{
description: "Update an existing item's fields",
inputSchema: {
id: z.string().uuid().describe("Item ID"),
title: z.string().min(1).optional().describe("New title"),
description: z.string().optional().describe("New description"),
start: z.string().optional().describe("New start date (ISO 8601)"),
end: z
.string()
.nullable()
.optional()
.describe("New end date (ISO 8601, or null to remove)"),
},
},
async ({ id, title, description, start, end }) => {
// Verify ownership through item → group → timeline
const item = await db.query.timelineItem.findFirst({
where: eq(timelineItem.id, id),
with: { timelineGroup: { with: { timeline: true } } },
});
if (!item || item.timelineGroup.timeline.ownerId !== userId) {
return {
content: [{ type: "text" as const, text: "Item not found" }],
isError: true,
};
}
const updates: Record<string, unknown> = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (start !== undefined) updates.start = new Date(start);
if (end !== undefined) updates.end = end ? new Date(end) : null;
const [updated] = await db
.update(timelineItem)
.set(updates)
.where(eq(timelineItem.id, id))
.returning();
return {
content: [{ type: "text" as const, text: JSON.stringify(updated) }],
};
}
);
// --- Delete tools ---
server.registerTool(
"delete_timeline",
{
description: "Delete a timeline and all its groups and items (cascades)",
inputSchema: { id: z.string().uuid().describe("Timeline ID") },
},
async ({ id }) => {
const deleted = await db
.delete(timeline)
.where(and(eq(timeline.id, id), eq(timeline.ownerId, userId)))
.returning();
if (deleted.length === 0) {
return {
content: [{ type: "text" as const, text: "Timeline not found" }],
isError: true,
};
}
return {
content: [{ type: "text" as const, text: "Timeline deleted" }],
};
}
);
server.registerTool(
"delete_group",
{
description: "Delete a group and all its items (cascades)",
inputSchema: { id: z.string().uuid().describe("Group ID") },
},
async ({ id }) => {
// Verify ownership
const group = await db.query.timelineGroup.findFirst({
where: eq(timelineGroup.id, id),
with: { timeline: true },
});
if (!group || group.timeline.ownerId !== userId) {
return {
content: [{ type: "text" as const, text: "Group not found" }],
isError: true,
};
}
await db.delete(timelineGroup).where(eq(timelineGroup.id, id));
return {
content: [{ type: "text" as const, text: "Group deleted" }],
};
}
);
server.registerTool(
"delete_item",
{
description: "Delete an item",
inputSchema: { id: z.string().uuid().describe("Item ID") },
},
async ({ id }) => {
// Verify ownership
const item = await db.query.timelineItem.findFirst({
where: eq(timelineItem.id, id),
with: { timelineGroup: { with: { timeline: true } } },
});
if (!item || item.timelineGroup.timeline.ownerId !== userId) {
return {
content: [{ type: "text" as const, text: "Item not found" }],
isError: true,
};
}
await db.delete(timelineItem).where(eq(timelineItem.id, id));
return {
content: [{ type: "text" as const, text: "Item deleted" }],
};
}
);
return server;
}
const handler = mcpHandler(
{
jwksUrl: `${env.BETTER_AUTH_URL}/api/auth/jwks`,
verifyOptions: {
issuer: `${env.BETTER_AUTH_URL}/api/auth`,
audience: [env.BETTER_AUTH_URL, new URL(env.BETTER_AUTH_URL).href],
},
},
async (req, jwt) => {
const userId = jwt.sub;
if (!userId) {
return new Response("Missing subject in token", { status: 401 });
}
const server = createMcpServer(userId);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
return transport.handleRequest(req);
}
);
export const Route = createFileRoute("/api/mcp/$")({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
DELETE: ({ request }) => handler(request),
},
},
});

View File

@@ -0,0 +1,54 @@
import { createFileRoute, useSearch } from "@tanstack/react-router";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/consent")({
validateSearch: (search: Record<string, unknown>) => ({
client_id: (search.client_id as string) ?? "",
scope: (search.scope as string) ?? "",
}),
component: ConsentComponent,
});
function ConsentComponent() {
const { client_id, scope } = useSearch({ from: "/consent" });
const scopes = scope.split(" ").filter(Boolean);
const handleAccept = async () => {
await authClient.oauth2.consent({ accept: true });
};
const handleDeny = async () => {
await authClient.oauth2.consent({ accept: false });
};
return (
<section className="container mx-auto max-w-md px-4 py-12">
<div className="space-y-6">
<h1 className="text-2xl font-serif font-bold">Authorize Application</h1>
<p className="text-muted-foreground">
<strong>{client_id}</strong> is requesting access to your account.
</p>
{scopes.length > 0 && (
<div className="space-y-2">
<p className="font-medium">Requested permissions:</p>
<ul className="list-inside list-disc space-y-1">
{scopes.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</div>
)}
<div className="flex gap-3">
<Button onClick={handleAccept}>Allow</Button>
<Button variant="outline" onClick={handleDeny}>
Deny
</Button>
</div>
</div>
</section>
);
}