Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ node_modules
# next.js
/.next/
/.next-build/
/.next-test-*/
/out/

# production
Expand Down
118 changes: 118 additions & 0 deletions app/api/eliza-app/auth/connection-success/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const PLATFORM_MESSAGES: Record<string, string> = {
web: "close this tab. your chat is ready.",
};

const PROVIDER_LABELS: Record<string, string> = {
google: "Google",
microsoft: "Microsoft",
twitter: "X",
github: "GitHub",
slack: "Slack",
};

function buildHtml(platform: string): string {
const instruction = PLATFORM_MESSAGES[platform] ?? PLATFORM_MESSAGES.web;

Expand Down Expand Up @@ -71,7 +79,117 @@ function buildHtml(platform: string): string {
</html>`;
}

function buildElizaAppHtml(provider: string, connectionId: string | null): string {
const providerLabel = PROVIDER_LABELS[provider] ?? "Your account";
const payload = JSON.stringify({
type: "eliza-app-oauth-complete",
provider,
connectionId,
connected: true,
});

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${providerLabel} connected</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: radial-gradient(circle at top, #1c2837, #0d0d0d 60%);
color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.card {
width: min(440px, 100%);
text-align: center;
padding: 32px 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
}
.check {
width: 64px;
height: 64px;
border-radius: 999px;
margin: 0 auto 20px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(52, 199, 89, 0.14);
color: #34c759;
font-size: 32px;
line-height: 1;
}
h1 {
margin: 0 0 12px;
font-size: 28px;
font-weight: 600;
}
p {
margin: 0;
color: #b5b5b5;
line-height: 1.6;
font-size: 16px;
}
button {
margin-top: 24px;
border: 0;
border-radius: 999px;
padding: 12px 18px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
cursor: pointer;
font: inherit;
}
</style>
</head>
<body>
<div class="card">
<div class="check">✓</div>
<h1>${providerLabel} connected.</h1>
<p>You can return to Eliza App now. If this window does not close automatically, close it manually.</p>
<button type="button" onclick="window.close()">Close Window</button>
</div>
<script>
(function () {
const payload = ${payload};
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage(payload, "*");
setTimeout(function () {
window.close();
}, 150);
} catch (_) {
// Best-effort only. The button remains available.
}
}
})();
</script>
</body>
</html>`;
}

export async function GET(request: NextRequest) {
const source = request.nextUrl.searchParams.get("source");
if (source === "eliza-app") {
const provider = request.nextUrl.searchParams.get("platform") || "connection";
const connectionId = request.nextUrl.searchParams.get("connection_id");

return new NextResponse(buildElizaAppHtml(provider, connectionId), {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

const platform = request.nextUrl.searchParams.get("platform") || "web";

if (platform === "web") {
Expand Down
85 changes: 85 additions & 0 deletions app/api/eliza-app/connections/[platform]/initiate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { elizaAppSessionService } from "@/lib/services/eliza-app";
import { oauthService } from "@/lib/services/oauth";
import { getProvider } from "@/lib/services/oauth/provider-registry";

interface InitiateBody {
returnPath?: string;
scopes?: string[];
}

function sanitizeReturnPath(path: string | undefined): string {
if (!path || !path.startsWith("/")) {
return "/connected";
}

return path;
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ platform: string }> },
): Promise<NextResponse> {
const authHeader = request.headers.get("Authorization");
if (!authHeader) {
return NextResponse.json(
{ error: "Authorization header required", code: "UNAUTHORIZED" },
{ status: 401 },
);
}

const session = await elizaAppSessionService.validateAuthHeader(authHeader);
if (!session) {
return NextResponse.json(
{ error: "Invalid or expired session", code: "INVALID_SESSION" },
{ status: 401 },
);
}

const { platform } = await params;
const normalizedPlatform = platform.toLowerCase();
const provider = getProvider(normalizedPlatform);
if (!provider) {
return NextResponse.json(
{ error: "Unsupported platform", code: "PLATFORM_NOT_SUPPORTED" },
{ status: 400 },
);
}

let body: InitiateBody = {};
try {
body = (await request.json()) as InitiateBody;
} catch {
// Empty body is fine.
}

const returnPath = sanitizeReturnPath(body.returnPath);
const redirectUrl = `/api/eliza-app/auth/connection-success?source=eliza-app&return_path=${encodeURIComponent(returnPath)}`;

try {
const result = await oauthService.initiateAuth({
organizationId: session.organizationId,
userId: session.userId,
platform: normalizedPlatform,
redirectUrl,
scopes: body.scopes,
});

return NextResponse.json({
authUrl: result.authUrl,
state: result.state,
provider: {
id: provider.id,
name: provider.name,
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to initiate OAuth",
code: "INITIATE_FAILED",
},
{ status: 500 },
);
}
}
70 changes: 70 additions & 0 deletions app/api/eliza-app/connections/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { elizaAppSessionService } from "@/lib/services/eliza-app";
import { getProvider } from "@/lib/services/oauth/provider-registry";

function getRequestedPlatform(request: NextRequest): string | null {
const platform = request.nextUrl.searchParams.get("platform")?.toLowerCase() || "google";
return getProvider(platform) ? platform : null;
}

export async function GET(request: NextRequest): Promise<NextResponse> {
const authHeader = request.headers.get("Authorization");
if (!authHeader) {
return NextResponse.json(
{ error: "Authorization header required", code: "UNAUTHORIZED" },
{ status: 401 },
);
}

const session = await elizaAppSessionService.validateAuthHeader(authHeader);
if (!session) {
return NextResponse.json(
{ error: "Invalid or expired session", code: "INVALID_SESSION" },
{ status: 401 },
);
}

const platform = getRequestedPlatform(request);
if (!platform) {
return NextResponse.json(
{ error: "Unsupported platform", code: "PLATFORM_NOT_SUPPORTED" },
{ status: 400 },
);
}

try {
const { oauthService } = await import("@/lib/services/oauth");
const connections = await oauthService.listConnections({
organizationId: session.organizationId,
userId: session.userId,
platform,
});

const active = connections.find((connection) => connection.status === "active");
const expired = connections.find((connection) => connection.status === "expired");
const current = active ?? expired ?? null;

return NextResponse.json({
platform,
connected: Boolean(active),
status: active ? "active" : expired ? "expired" : "not_connected",
email: current?.email ?? null,
scopes: current?.scopes ?? [],
linkedAt: current?.linkedAt?.toISOString() ?? null,
connectionId: current?.id ?? null,
message: active
? null
: expired
? "Connection expired. Reconnect Google to keep Gmail and Calendar working."
: "Not connected yet.",
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load connection status",
code: "CONNECTION_STATUS_FAILED",
},
{ status: 500 },
);
}
}
1 change: 1 addition & 0 deletions app/api/eliza-app/webhook/blooio/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ async function handleIncomingMessage(event: BlooioWebhookEvent): Promise<boolean

const hasRequiredConnection = await connectionEnforcementService.hasRequiredConnection(
organization.id,
userWithOrg.id,
);
if (!hasRequiredConnection) {
const nudgeText = await connectionEnforcementService.generateNudgeResponse({
Expand Down
1 change: 1 addition & 0 deletions app/api/eliza-app/webhook/discord/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ async function handleDiscordWebhook(request: NextRequest): Promise<NextResponse>

const hasRequiredConnection = await connectionEnforcementService.hasRequiredConnection(
organization.id,
userWithOrg.id,
);
if (!hasRequiredConnection) {
const nudgeText = await connectionEnforcementService.generateNudgeResponse({
Expand Down
2 changes: 2 additions & 0 deletions app/api/eliza-app/webhook/telegram/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ async function handleMessage(message: Message): Promise<void> {

const hasRequiredConnection = await connectionEnforcementService.hasRequiredConnection(
organization.id,
userWithOrg.id,
);
if (!hasRequiredConnection) {
const nudgeText = await connectionEnforcementService.generateNudgeResponse({
Expand Down Expand Up @@ -393,6 +394,7 @@ async function handleCommand(message: Message & { text: string }): Promise<void>
const creditBalance = user.organization.credit_balance || "0.00";
const hasRequiredConnection = await connectionEnforcementService.hasRequiredConnection(
user.organization.id,
user.id,
);
const connectionStatus = hasRequiredConnection
? "✅ Data integration connected"
Expand Down
Loading
Loading