mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Merge branch 'main' into dev
This commit is contained in:
+2
-1
@@ -54,7 +54,6 @@ AUTH_OIDC_ROLE_MAP=""
|
|||||||
AUTH_ALLOW_LINKING=true
|
AUTH_ALLOW_LINKING=true
|
||||||
AUTH_ALLOW_UNLINKING=true
|
AUTH_ALLOW_UNLINKING=true
|
||||||
|
|
||||||
|
|
||||||
# Social OAuth2 Authentification
|
# Social OAuth2 Authentification
|
||||||
AUTH_SOCIAL_ID=""
|
AUTH_SOCIAL_ID=""
|
||||||
AUTH_SOCIAL_TITLE=""
|
AUTH_SOCIAL_TITLE=""
|
||||||
@@ -76,3 +75,5 @@ RETENTION_CRON="* * * * *"
|
|||||||
|
|
||||||
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
|
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
|
||||||
|
|
||||||
|
#OPENAPI_ENABLED=true
|
||||||
|
#API_ENABLED=true
|
||||||
@@ -24,50 +24,86 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
notify-discord:
|
notify-discord:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Send Discord Notification
|
- name: Send Discord Notification
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
|
DISCORD_TITLE: ${{ inputs.discord_title }}
|
||||||
|
DISCORD_COLOR: ${{ inputs.discord_color }}
|
||||||
|
DISCORD_FOOTER: ${{ inputs.discord_footer }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author)
|
set -euo pipefail
|
||||||
|
|
||||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
RELEASE_INFO=$(gh release view "$RELEASE_TAG" -R "${{ github.repository }}" --json name,url,body,author)
|
||||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi
|
|
||||||
|
|
||||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r '.name // empty')
|
||||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
if [ -z "$RELEASE_TITLE" ]; then
|
||||||
|
RELEASE_TITLE="$RELEASE_TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r '.url // empty')
|
||||||
|
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r '.body // ""')
|
||||||
|
|
||||||
AUTHOR_NAME="Portabase"
|
AUTHOR_NAME="Portabase"
|
||||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
jq -n \
|
||||||
--arg title "$RELEASE_TITLE" \
|
--arg title "$RELEASE_TITLE" \
|
||||||
--arg description "$RELEASE_BODY" \
|
--arg description "$RELEASE_BODY" \
|
||||||
--arg url "$RELEASE_URL" \
|
--arg url "$RELEASE_URL" \
|
||||||
--arg author "$AUTHOR_NAME" \
|
--arg author "$AUTHOR_NAME" \
|
||||||
--arg icon "$AUTHOR_ICON" \
|
--arg icon "$AUTHOR_ICON" \
|
||||||
--arg discord_title "${{ inputs.discord_title }}" \
|
--arg discord_title "$DISCORD_TITLE" \
|
||||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
--arg discord_footer "$DISCORD_FOOTER" \
|
||||||
--argjson discord_color ${{ inputs.discord_color }} \
|
--argjson discord_color "$DISCORD_COLOR" \
|
||||||
'{
|
'{
|
||||||
content: $discord_title,
|
content: (
|
||||||
|
if ($discord_title | length) > 1900
|
||||||
|
then ($discord_title[0:1900] + "...")
|
||||||
|
else $discord_title
|
||||||
|
end
|
||||||
|
),
|
||||||
embeds: [{
|
embeds: [{
|
||||||
title: $title,
|
title: (
|
||||||
|
if ($title | length) > 256
|
||||||
|
then ($title[0:253] + "...")
|
||||||
|
else $title
|
||||||
|
end
|
||||||
|
),
|
||||||
url: $url,
|
url: $url,
|
||||||
description: $description,
|
description: (
|
||||||
|
if ($description | length) > 3800
|
||||||
|
then ($description[0:3800] + "\n\n...")
|
||||||
|
else $description
|
||||||
|
end
|
||||||
|
),
|
||||||
color: $discord_color,
|
color: $discord_color,
|
||||||
author: {
|
author: {
|
||||||
name: $author,
|
name: (
|
||||||
|
if ($author | length) > 256
|
||||||
|
then ($author[0:253] + "...")
|
||||||
|
else $author
|
||||||
|
end
|
||||||
|
),
|
||||||
icon_url: $icon
|
icon_url: $icon
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
text: $discord_footer
|
text: (
|
||||||
|
if ($discord_footer | length) > 512
|
||||||
|
then ($discord_footer[0:509] + "...")
|
||||||
|
else $discord_footer
|
||||||
|
end
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}'
|
}' > payload.json
|
||||||
)
|
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" \
|
cat payload.json | jq .
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"$DISCORD_WEBHOOK"
|
curl --fail-with-body -sS \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @payload.json \
|
||||||
|
"$DISCORD_WEBHOOK?wait=true"
|
||||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.15.5
|
version: 1.16.2
|
||||||
date-released: '2026-03-02'
|
date-released: '2026-03-02'
|
||||||
|
|||||||
@@ -1,4 +1,35 @@
|
|||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
const authHandler = toNextJsHandler(auth.handler);
|
||||||
|
|
||||||
|
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
|
||||||
|
const url = req.nextUrl;
|
||||||
|
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
if (session.user.banned || (session.user.role as string) === "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Account not eligible to create API keys" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return authHandler.GET(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const guard = await blockApiKeyCreateForRestrictedUsers(req);
|
||||||
|
if (guard) return guard;
|
||||||
|
return authHandler.POST(req);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
PROJECT_URL: process.env.PROJECT_URL,
|
PROJECT_URL: process.env.PROJECT_URL,
|
||||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {withApiKey} from "@/lib/api-v1/middleware";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
import {generateEdgeKey} from "@/utils/edge_key";
|
||||||
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/agents/[id]/key" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const access = await resolveAgentAccess(id, ctx.user);
|
||||||
|
|
||||||
|
if (access === "forbidden") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
if (access === "not_found") return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const agent = await getAgent(id);
|
||||||
|
|
||||||
|
if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||||
|
|
||||||
|
return NextResponse.json({ data: edgeKey });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/agents/[id]");
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {withApiKey} from "@/lib/api-v1/middleware";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
|
||||||
|
import {deleteAgentService} from "@/features/agents/agent-delete.action";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/v1/agents/[id]"});
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
const access = await resolveAgentAccess(id, ctx.user);
|
||||||
|
|
||||||
|
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
const agent = await getAgent(id, {
|
||||||
|
includeDatabases: true,
|
||||||
|
includeOrganizations: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
return NextResponse.json({data: agent});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error}, "Error in GET /api/v1/agents/[id]");
|
||||||
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DELETE = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
const access = await resolveAgentAccess(id, ctx.user);
|
||||||
|
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
|
||||||
|
const agent = await getAgent(id, {
|
||||||
|
includeDatabases: false,
|
||||||
|
includeOrganizations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) return NextResponse.json({error: "Agent no found"}, {status: 404});
|
||||||
|
|
||||||
|
const organizationIds = agent.organizations.map(org => org.organizationId)
|
||||||
|
|
||||||
|
const canDeleteGlobalAgent =
|
||||||
|
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
|
||||||
|
|
||||||
|
if (!agent.organizationId && !canDeleteGlobalAgent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAgentService({
|
||||||
|
agentId: agent.id,
|
||||||
|
organizationId: agent.organizationId ?? undefined,
|
||||||
|
organizationIds: organizationIds
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {status: 204});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error}, "Error in DELETE /api/v1/agents/[id]");
|
||||||
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { inArray, eq, and, or, isNull } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import {createAgentService} from "@/features/agents/agents.action";
|
||||||
|
import { ActionError } from "@/lib/safe-actions/actions";
|
||||||
|
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
import {parseJsonBody} from "@/lib/api-v1/validation/json-body";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/agents" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
|
||||||
|
try {
|
||||||
|
const agentIds = await getAccessibleAgentIds(ctx.user);
|
||||||
|
|
||||||
|
if (agentIds.length === 0) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await db.query.agent.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(drizzleDb.schemas.agent.id, agentIds),
|
||||||
|
or(
|
||||||
|
eq(drizzleDb.schemas.agent.isArchived, false),
|
||||||
|
isNull(drizzleDb.schemas.agent.isArchived)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: agents });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/agents");
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateAgentSchema = z.object({
|
||||||
|
name: z.string().min(1, "name is required"),
|
||||||
|
organizationId: z.string().uuid("organizationId must be a valid UUID").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withApiKey(
|
||||||
|
async (req: Request, ctx: ApiKeyContext) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const body = await parseJsonBody(req, CreateAgentSchema);
|
||||||
|
|
||||||
|
if (!body.ok) {
|
||||||
|
return body.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, organizationId } = body.data;
|
||||||
|
|
||||||
|
const org = ctx.organizations.find(
|
||||||
|
(org) => org.id === organizationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
organizationId &&
|
||||||
|
(!org || !org.permissions.canManageAgents)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreateGlobalAgent =
|
||||||
|
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
|
||||||
|
|
||||||
|
if (!organizationId && !canCreateGlobalAgent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAgent = await createAgentService({
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ data: createdAgent },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ActionError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error({error},
|
||||||
|
"Error in POST /api/v1/agents"
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({
|
||||||
|
module: "api/v1/databases/[id]/backup/[backupId]",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
const backupId = params?.backupId;
|
||||||
|
|
||||||
|
if (!backupId) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
storages: {
|
||||||
|
where: (backupStorage, { isNull }) =>
|
||||||
|
isNull(backupStorage.deletedAt),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: backup });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
{ error },
|
||||||
|
"Error in GET /api/v1/databases/[id]/backup/[backupId]"
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]/backup" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const backups = await db.query.backup.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: backups });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases/[id]/backup");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const POST = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const existingBackup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingBackup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"A backup is already waiting or ongoing for this database",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [createdBackup] = await db
|
||||||
|
.insert(drizzleDb.schemas.backup)
|
||||||
|
.values({
|
||||||
|
databaseId: id,
|
||||||
|
status: "waiting",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!createdBackup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create backup" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: createdBackup }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in POST /api/v1/databases/[id]/backup");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import { parseJsonBody } from "@/lib/api-v1/validation/json-body";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]/restore" });
|
||||||
|
|
||||||
|
const RestoreSchema = z.object({
|
||||||
|
backupId: z.string().uuid("backupId must be a valid UUID"),
|
||||||
|
backupStorageId: z.string().uuid("backupStorageId must be a valid UUID"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withApiKey(
|
||||||
|
async (req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const body = await parseJsonBody(req, RestoreSchema);
|
||||||
|
|
||||||
|
if (!body.ok) {
|
||||||
|
return body.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { backupId, backupStorageId } = body.data;
|
||||||
|
|
||||||
|
const backupRecord = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backupRecord) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Backup not found for this database" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupStorage = await db.query.backupStorage.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||||
|
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
|
||||||
|
isNull(drizzleDb.schemas.backupStorage.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backupStorage) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Backup storage not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupStorage.status !== "success") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Backup storage is not in a successful state" },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRestorationBackup = await db.query.restoration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, id),
|
||||||
|
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRestorationBackup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"A restoration is already waiting or ongoing for this database",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [restoration] = await db
|
||||||
|
.insert(drizzleDb.schemas.restoration)
|
||||||
|
.values({
|
||||||
|
databaseId: id,
|
||||||
|
backupId,
|
||||||
|
backupStorageId,
|
||||||
|
status: "waiting",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!restoration) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create restoration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: restoration }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in POST /api/v1/databases/[id]/restore");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, id),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: database });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases/[id]");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]/status" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const [database, latestBackup, latestRestoration] = await Promise.all([
|
||||||
|
db.query.database.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, id),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.query.restoration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.restoration.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(drizzleDb.schemas.restoration.createdAt)],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Database not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
isWaitingForBackup: database.isWaitingForBackup,
|
||||||
|
lastContact: database.lastContact,
|
||||||
|
latestBackup: latestBackup ?? null,
|
||||||
|
latestRestoration: latestRestoration ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases/[id]/status");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import { getAccessibleDatabases } from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
|
||||||
|
try {
|
||||||
|
const databases = await getAccessibleDatabases(ctx.user);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: databases,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Portabase API Docs</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui.css"
|
||||||
|
integrity="sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-bundle.js" integrity="sha384-EYdOaiRwn44zNjrw+Tfs06qYz9BGQVo2f4/pLY5i7VorbjnZNhdplAbTBk8FXHUJ" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-standalone-preset.js" integrity="sha384-49fpFaVrAWI/qdgl9Vv5E/4NXxRUiJX5vGuLws1NUpTWGtEqzWEx8gHTw2UTehFK" crossorigin="anonymous"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function () {
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: "/api/v1/openapi",
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset,
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout",
|
||||||
|
persistAuthorization: true,
|
||||||
|
filter: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { buildSpec } from "@/lib/api-v1/openapi/spec";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json(buildSpec());
|
||||||
|
}
|
||||||
+33
-31
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.15.5",
|
"version": "1.16.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -15,10 +15,12 @@
|
|||||||
"release": "release-it"
|
"release": "release-it"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/core": "1.6.2",
|
"@asteasolutions/zod-to-openapi": "^8.5.0",
|
||||||
"@better-auth/passkey": "1.6.2",
|
"@better-auth/api-key": "1.6.11",
|
||||||
"@better-auth/sso": "1.6.2",
|
"@better-auth/core": "1.6.11",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@better-auth/passkey": "1.6.11",
|
||||||
|
"@better-auth/sso": "1.6.11",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
@@ -49,19 +51,19 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-email/components": "^0.0.41",
|
"@react-email/components": "^0.0.41",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"@tanstack/react-query": "^5.97.0",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/nodemailer": "^6.4.23",
|
"@types/nodemailer": "^6.4.23",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
"argon2": "^0.43.1",
|
"argon2": "^0.43.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "1.6.2",
|
"better-auth": "1.6.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.3.0",
|
||||||
"dockerode": "^4.0.10",
|
"dockerode": "^4.0.12",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.40.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -78,17 +80,17 @@
|
|||||||
"node-forge": "^1.4.0",
|
"node-forge": "^1.4.0",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.5",
|
||||||
"npm-check-updates": "^18.3.1",
|
"npm-check-updates": "^18.3.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.21.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prettier": "^3.8.2",
|
"prettier": "^3.8.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "9.7.0",
|
"react-day-picker": "9.7.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.3.2",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.21",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-twc": "^1.5.1",
|
"react-twc": "^1.5.1",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
@@ -97,44 +99,44 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.1.3",
|
"swiper": "^12.1.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@react-email/preview-server": "4.3.2",
|
"@react-email/preview-server": "4.3.2",
|
||||||
"@react-email/render": "^2.0.6",
|
"@react-email/render": "^2.0.8",
|
||||||
"@release-it/bumper": "^7.0.5",
|
"@release-it/bumper": "^7.0.5",
|
||||||
"@release-it/conventional-changelog": "^10.0.6",
|
"@release-it/conventional-changelog": "^10.0.6",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.19.17",
|
"@types/node": "^22.19.19",
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@zenstackhq/openapi": "^2.22.1",
|
"@zenstackhq/openapi": "^2.22.1",
|
||||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||||
"baseline-browser-mapping": "^2.10.17",
|
"baseline-browser-mapping": "^2.10.32",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"esbuild": "^0.27.7",
|
"esbuild": "^0.27.7",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.3",
|
"eslint-config-next": "^16.2.6",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.40.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"postcss": "8.5.10",
|
"postcss": "8.5.10",
|
||||||
"release-it": "^19.2.4",
|
"release-it": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.22.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zenstack": "2.14.2"
|
"zenstack": "2.14.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.3"
|
"packageManager": "pnpm@11.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1029
-969
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
|||||||
"https://code.iconify.design",
|
"https://code.iconify.design",
|
||||||
"https://cdn.iconify.design",
|
"https://cdn.iconify.design",
|
||||||
"https://code.iconify.com",
|
"https://code.iconify.com",
|
||||||
|
"https://cdn.jsdelivr.net",
|
||||||
],
|
],
|
||||||
IMG_SRC: [
|
IMG_SRC: [
|
||||||
"'self'",
|
"'self'",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
|||||||
import { errorHandler } from "@/middleware/errorHandler";
|
import { errorHandler } from "@/middleware/errorHandler";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
@@ -17,11 +19,13 @@ export async function proxy(request: NextRequest) {
|
|||||||
new URL(`/login?redirect=${redirectUrl}`, request.url),
|
new URL(`/login?redirect=${redirectUrl}`, request.url),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (session.user.banned) {
|
const user = session.user as User
|
||||||
|
|
||||||
|
if (user.banned) {
|
||||||
await auth.api.signOut({ headers: await headers() });
|
await auth.api.signOut({ headers: await headers() });
|
||||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||||
}
|
}
|
||||||
if (session.user.role === "pending") {
|
if (user.role === "pending") {
|
||||||
await auth.api.signOut({ headers: await headers() });
|
await auth.api.signOut({ headers: await headers() });
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url),
|
new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url),
|
||||||
@@ -38,6 +42,33 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api")) {
|
if (url.pathname.startsWith("/api")) {
|
||||||
|
if (url.pathname.startsWith("/api/v1")) {
|
||||||
|
const apiEnabled = String(env.API_ENABLED) === "true";
|
||||||
|
if (!apiEnabled) {
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "This API route does not exist.",
|
||||||
|
status: 404,
|
||||||
|
}),
|
||||||
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const openapiEnabled = String(env.OPENAPI_ENABLED) === "true";
|
||||||
|
if (
|
||||||
|
!openapiEnabled &&
|
||||||
|
(url.pathname.startsWith("/api/v1/docs") ||
|
||||||
|
url.pathname.startsWith("/api/v1/openapi"))
|
||||||
|
) {
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "This API route does not exist.",
|
||||||
|
status: 404,
|
||||||
|
}),
|
||||||
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const routeExists = checkRouteExists(url.pathname);
|
const routeExists = checkRouteExists(url.pathname);
|
||||||
if (!routeExists) {
|
if (!routeExists) {
|
||||||
return new NextResponse(
|
return new NextResponse(
|
||||||
@@ -73,6 +104,18 @@ function checkRouteExists(pathname: string) {
|
|||||||
/^\/api\/config\/?$/,
|
/^\/api\/config\/?$/,
|
||||||
/^\/api\/health\/?$/,
|
/^\/api\/health\/?$/,
|
||||||
/^\/api\/google\/drive\/callback\/?$/,
|
/^\/api\/google\/drive\/callback\/?$/,
|
||||||
|
// v1 external API
|
||||||
|
/^\/api\/v1\/docs\/?$/,
|
||||||
|
/^\/api\/v1\/openapi\/?$/,
|
||||||
|
/^\/api\/v1\/agents\/?$/,
|
||||||
|
/^\/api\/v1\/agents\/[^/]+\/?$/,
|
||||||
|
/^\/api\/v1\/agents\/[^/]+\/key\/?$/,
|
||||||
|
/^\/api\/v1\/databases\/?$/,
|
||||||
|
/^\/api\/v1\/databases\/[^/]+\/?$/,
|
||||||
|
/^\/api\/v1\/databases\/[^/]+\/backup\/?$/,
|
||||||
|
/^\/api\/v1\/databases\/[^/]+\/backup\/[^/]+\/?$/,
|
||||||
|
/^\/api\/v1\/databases\/[^/]+\/restore\/?$/,
|
||||||
|
/^\/api\/v1\/databases\/[^/]+\/status\/?$/,
|
||||||
];
|
];
|
||||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -16,6 +16,7 @@ import * as storageChannel from "./schema/12_storage-channel";
|
|||||||
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||||
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||||
|
import * as apiKey from "@/db/schema/16_apikey";
|
||||||
|
|
||||||
const log = logger.child({module: "db"});
|
const log = logger.child({module: "db"});
|
||||||
|
|
||||||
@@ -51,7 +52,8 @@ export const schemas = {
|
|||||||
...storageChannel,
|
...storageChannel,
|
||||||
...storagePolicy,
|
...storagePolicy,
|
||||||
...backupStorage,
|
...backupStorage,
|
||||||
...healthcheckLog
|
...healthcheckLog,
|
||||||
|
...apiKey
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE "apikey" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"config_id" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"start" text,
|
||||||
|
"prefix" text,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"reference_id" text NOT NULL,
|
||||||
|
"refill_interval" integer,
|
||||||
|
"refill_amount" integer,
|
||||||
|
"last_refill_at" timestamp (6) with time zone,
|
||||||
|
"enabled" boolean,
|
||||||
|
"rate_limit_enabled" boolean,
|
||||||
|
"rate_limit_time_window" integer,
|
||||||
|
"rate_limit_max" integer,
|
||||||
|
"request_count" integer,
|
||||||
|
"remaining" integer,
|
||||||
|
"last_request" timestamp (6) with time zone,
|
||||||
|
"expires_at" timestamp (6) with time zone,
|
||||||
|
"created_at" timestamp (6) with time zone NOT NULL,
|
||||||
|
"updated_at" timestamp (6) with time zone NOT NULL,
|
||||||
|
"permissions" text,
|
||||||
|
"metadata" text
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -400,6 +400,13 @@
|
|||||||
"when": 1779380470656,
|
"when": 1779380470656,
|
||||||
"tag": "0056_lazy_cyclops",
|
"tag": "0056_lazy_cyclops",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 57,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779698258492,
|
||||||
|
"tag": "0057_cooing_nocturne",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
import { createSelectSchema } from "drizzle-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { project } from "./06_project";
|
|
||||||
import { member } from "@/db/schema/04_member";
|
import { member } from "@/db/schema/04_member";
|
||||||
import { invitation } from "@/db/schema/05_invitation";
|
import { invitation } from "@/db/schema/05_invitation";
|
||||||
import { organization } from "@/db/schema/03_organization";
|
import { organization } from "@/db/schema/03_organization";
|
||||||
@@ -144,12 +143,6 @@ export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const projectRelations = relations(project, ({ one }) => ({
|
|
||||||
organization: one(organization, {
|
|
||||||
fields: [project.organizationId],
|
|
||||||
references: [organization.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {pgTable, uuid} from "drizzle-orm/pg-core";
|
||||||
|
import * as t from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const apikey = pgTable("apikey", {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
configId: t.text("config_id").notNull(),
|
||||||
|
name: t.text("name"),
|
||||||
|
start: t.text("start"),
|
||||||
|
prefix: t.text("prefix"),
|
||||||
|
key: t.text("key").notNull(),
|
||||||
|
referenceId: t.text("reference_id").notNull(),
|
||||||
|
refillInterval: t.integer("refill_interval"),
|
||||||
|
refillAmount: t.integer("refill_amount"),
|
||||||
|
lastRefillAt: t.timestamp("last_refill_at", { precision: 6, withTimezone: true }),
|
||||||
|
enabled: t.boolean("enabled"),
|
||||||
|
rateLimitEnabled: t.boolean("rate_limit_enabled"),
|
||||||
|
rateLimitTimeWindow: t.integer("rate_limit_time_window"),
|
||||||
|
rateLimitMax: t.integer("rate_limit_max"),
|
||||||
|
requestCount: t.integer("request_count"),
|
||||||
|
remaining: t.integer("remaining"),
|
||||||
|
lastRequest: t.timestamp("last_request", { precision: 6, withTimezone: true }),
|
||||||
|
expiresAt: t.timestamp("expires_at", { precision: 6, withTimezone: true }),
|
||||||
|
createdAt: t.timestamp("created_at", { precision: 6, withTimezone: true }).notNull(),
|
||||||
|
updatedAt: t.timestamp("updated_at", { precision: 6, withTimezone: true }).notNull(),
|
||||||
|
permissions: t.text("permissions"),
|
||||||
|
metadata: t.text("metadata"),
|
||||||
|
});
|
||||||
+13
@@ -98,6 +98,16 @@ export const env = createEnv({
|
|||||||
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
|
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
|
||||||
|
|
||||||
PRIVATE_PATH: z.string().optional(),
|
PRIVATE_PATH: z.string().optional(),
|
||||||
|
|
||||||
|
OPENAPI_ENABLED: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((val) => val === "true")
|
||||||
|
.default("false"),
|
||||||
|
|
||||||
|
API_ENABLED: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((val) => val === "true")
|
||||||
|
.default("false"),
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||||
@@ -170,5 +180,8 @@ export const env = createEnv({
|
|||||||
AUTH_DEFAULT_USER_NAME: process.env.AUTH_DEFAULT_USER_NAME,
|
AUTH_DEFAULT_USER_NAME: process.env.AUTH_DEFAULT_USER_NAME,
|
||||||
AUTH_DEFAULT_USER: process.env.AUTH_DEFAULT_USER,
|
AUTH_DEFAULT_USER: process.env.AUTH_DEFAULT_USER,
|
||||||
AUTH_DEFAULT_PASSWORD: process.env.AUTH_DEFAULT_PASSWORD,
|
AUTH_DEFAULT_PASSWORD: process.env.AUTH_DEFAULT_PASSWORD,
|
||||||
|
|
||||||
|
OPENAPI_ENABLED: process.env.OPENAPI_ENABLED,
|
||||||
|
API_ENABLED: process.env.API_ENABLED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,127 +11,176 @@ import {userAction} from "@/lib/safe-actions/actions";
|
|||||||
import {zString} from "@/lib/zod";
|
import {zString} from "@/lib/zod";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
|
||||||
|
type DeleteAgentInput = {
|
||||||
|
organizationId?: string;
|
||||||
|
agentId: string;
|
||||||
|
organizationIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
class AgentNotFoundError extends Error {
|
||||||
|
constructor(agentId: string) {
|
||||||
|
super(`Agent not found or update failed: ${agentId}`);
|
||||||
|
this.name = "AgentNotFoundError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAgentService(input: DeleteAgentInput): Promise<Agent> {
|
||||||
|
const {agentId, organizationId, organizationIds} = input;
|
||||||
|
|
||||||
|
let projectIds: string[] = [];
|
||||||
|
|
||||||
|
const uuid = uuidv4();
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.organizationAgent)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.organizationAgent.organizationId, organizationId),
|
||||||
|
eq(drizzleDb.schemas.organizationAgent.agentId, agentId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const organization = await db.query.organization.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.organization.id, organizationId),
|
||||||
|
with: {
|
||||||
|
projects: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
projectIds = organization?.projects?.map((project) => project.id) ?? [];
|
||||||
|
} else if (organizationIds?.length) {
|
||||||
|
const organizationsToRemoveDetails = await db.query.organization.findMany({
|
||||||
|
where: inArray(drizzleDb.schemas.organization.id, organizationIds),
|
||||||
|
with: {
|
||||||
|
projects: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
projectIds = organizationsToRemoveDetails.flatMap((org) =>
|
||||||
|
org.projects.map((project) => project.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
const databases = await db.query.database.findMany({
|
||||||
|
where: (database, {inArray}) => inArray(database.projectId, projectIds),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const databaseIds = databases.map((database) => database.id);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.database)
|
||||||
|
.set(
|
||||||
|
withUpdatedAt({
|
||||||
|
backupPolicy: null,
|
||||||
|
projectId: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (databaseIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.retentionPolicy)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
drizzleDb.schemas.retentionPolicy.databaseId,
|
||||||
|
databaseIds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.alertPolicy)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
drizzleDb.schemas.alertPolicy.databaseId,
|
||||||
|
databaseIds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.storagePolicy)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
drizzleDb.schemas.storagePolicy.databaseId,
|
||||||
|
databaseIds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedAgent] = await db
|
||||||
|
.update(drizzleDb.schemas.agent)
|
||||||
|
.set(
|
||||||
|
withUpdatedAt({
|
||||||
|
isArchived: true,
|
||||||
|
slug: uuid,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.where(eq(drizzleDb.schemas.agent.id, agentId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedAgent) {
|
||||||
|
throw new AgentNotFoundError(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedAgent;
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteAgentAction = userAction
|
export const deleteAgentAction = userAction
|
||||||
.schema(
|
.schema(
|
||||||
z.object({
|
z.object({
|
||||||
agentId: zString(),
|
agentId: zString(),
|
||||||
organizationId: zString().optional(),
|
organizationId: zString().optional(),
|
||||||
organizationIds: z.array(z.string()).optional()
|
organizationIds: z.array(zString()).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
|
.action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
|
||||||
const {agentId, organizationId, organizationIds} = parsedInput;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let projectIds: string[] = [];
|
const deletedAgent = await deleteAgentService(parsedInput);
|
||||||
|
|
||||||
const uuid = uuidv4();
|
return {
|
||||||
if (organizationId) {
|
success: true,
|
||||||
await db
|
value: deletedAgent,
|
||||||
.delete(drizzleDb.schemas.organizationAgent)
|
actionSuccess: {
|
||||||
.where(
|
message: "Agent has been successfully deleted.",
|
||||||
and(
|
messageParams: {
|
||||||
eq(drizzleDb.schemas.organizationAgent.organizationId, organizationId),
|
agentId: parsedInput.agentId,
|
||||||
eq(drizzleDb.schemas.organizationAgent.agentId, agentId)
|
},
|
||||||
)
|
},
|
||||||
);
|
};
|
||||||
|
} catch (error) {
|
||||||
const organization = await db.query.organization.findFirst({
|
if (error instanceof AgentNotFoundError) {
|
||||||
where: eq(drizzleDb.schemas.organization.id, organizationId),
|
|
||||||
with: {
|
|
||||||
projects: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
projectIds = organization?.projects?.map(project => project.id) ?? [];
|
|
||||||
|
|
||||||
|
|
||||||
} else if (organizationIds) {
|
|
||||||
|
|
||||||
const organizationsToRemoveDetails = await db.query.organization.findMany({
|
|
||||||
where: inArray(drizzleDb.schemas.organization.id, organizationIds),
|
|
||||||
with: {
|
|
||||||
projects: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
projectIds = organizationsToRemoveDetails.flatMap(org =>
|
|
||||||
org.projects.map(project => project.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (projectIds?.length > 0) {
|
|
||||||
const databases = await db.query.database.findMany({
|
|
||||||
where: (db, {inArray}) => inArray(db.projectId, projectIds),
|
|
||||||
columns: {id: true}
|
|
||||||
});
|
|
||||||
|
|
||||||
const databaseIds = databases.map(d => d.id);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(drizzleDb.schemas.database)
|
|
||||||
.set(withUpdatedAt({
|
|
||||||
backupPolicy: null,
|
|
||||||
projectId: null
|
|
||||||
}))
|
|
||||||
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.delete(drizzleDb.schemas.retentionPolicy)
|
|
||||||
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.delete(drizzleDb.schemas.alertPolicy)
|
|
||||||
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.delete(drizzleDb.schemas.storagePolicy)
|
|
||||||
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const updatedAgent = await db
|
|
||||||
.update(drizzleDb.schemas.agent)
|
|
||||||
.set(withUpdatedAt({
|
|
||||||
isArchived: true,
|
|
||||||
slug: uuid,
|
|
||||||
deletedAt: new Date()
|
|
||||||
}))
|
|
||||||
.where(eq(drizzleDb.schemas.agent.id, agentId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
|
|
||||||
if (!updatedAgent[0]) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
actionError: {
|
actionError: {
|
||||||
message: "Agent not found or update failed",
|
message: "Agent not found or update failed",
|
||||||
status: 404,
|
status: 404,
|
||||||
messageParams: {agentId: agentId},
|
messageParams: {
|
||||||
|
agentId: parsedInput.agentId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
value: updatedAgent[0],
|
|
||||||
actionSuccess: {
|
|
||||||
message: "Agent has been successfully deleted.",
|
|
||||||
messageParams: {projectId: agentId},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
actionError: {
|
actionError: {
|
||||||
message: "Failed to delete agent.",
|
message: "Failed to delete agent.",
|
||||||
status: 500,
|
status: 500,
|
||||||
cause: error instanceof Error ? error.message : "Unknown error",
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
messageParams: {agentId: agentId},
|
messageParams: {
|
||||||
|
agentId: parsedInput.agentId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import {ActionError, userAction} from "@/lib/safe-actions/actions";
|
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||||
import {AgentSchema} from "@/features/agents/agents.schema";
|
import {AgentSchema} from "@/features/agents/agents.schema";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {eq, and, ne, count} from "drizzle-orm";
|
import {eq, and, ne, count} from "drizzle-orm";
|
||||||
@@ -16,24 +16,47 @@ const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type CreateAgentInput = {
|
||||||
|
organizationId?: string;
|
||||||
|
data: z.infer<typeof AgentSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createAgentService(input: CreateAgentInput) {
|
||||||
|
const slug = slugify(input.data.name);
|
||||||
|
|
||||||
|
await verifySlugUniqueness(slug);
|
||||||
|
|
||||||
|
const [createdAgent] = await db
|
||||||
|
.insert(drizzleDb.schemas.agent)
|
||||||
|
.values({
|
||||||
|
...input.data,
|
||||||
|
slug,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (createdAgent && input.organizationId) {
|
||||||
|
await db.insert(drizzleDb.schemas.organizationAgent).values({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
agentId: createdAgent.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const createAgentAction = userAction.schema(
|
export const createAgentAction = userAction.schema(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string().optional(),
|
organizationId: z.string().optional(),
|
||||||
data: AgentSchema,
|
data: AgentSchema,
|
||||||
})
|
})
|
||||||
).action(async ({parsedInput}) => {
|
).action(async ({parsedInput}) => {
|
||||||
const slug = slugify(parsedInput.data.name);
|
const createdAgent = await createAgentService(parsedInput);
|
||||||
await verifySlugUniqueness(slug);
|
|
||||||
|
|
||||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput.data, slug: slug, organizationId: parsedInput.organizationId}).returning();
|
|
||||||
|
|
||||||
if (createdAgent && parsedInput.organizationId){
|
|
||||||
await db.insert(drizzleDb.schemas.organizationAgent).values({
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
agentId: createdAgent.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: createdAgent,
|
data: createdAgent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
|
|
||||||
import {SidebarTrigger} from "@/components/ui/sidebar";
|
import {SidebarTrigger} from "@/components/ui/sidebar";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
import {BreadCrumbsWrapper} from "@/components/common/bread-crumbs";
|
import {BreadCrumbsWrapper} from "@/components/common/bread-crumbs";
|
||||||
@@ -18,7 +17,6 @@ export const Header = async ({ actions }: { actions?: ReactNode } = {}) => {
|
|||||||
<SidebarTrigger className="-ml-1"/>
|
<SidebarTrigger className="-ml-1"/>
|
||||||
<BreadCrumbsWrapper/>
|
<BreadCrumbsWrapper/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GitHubStarsButtonCustom/>
|
<GitHubStarsButtonCustom/>
|
||||||
{actions}
|
{actions}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { currentUser } from "@/lib/auth/current-user";
|
|||||||
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||||
import { LoggedInButtonClient } from "./logged-in-button";
|
import { LoggedInButtonClient } from "./logged-in-button";
|
||||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export const LoggedInButton = async () => {
|
export const LoggedInButton = async () => {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
@@ -11,6 +12,7 @@ export const LoggedInButton = async () => {
|
|||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoggedInButtonClient
|
<LoggedInButtonClient
|
||||||
user={user}
|
user={user}
|
||||||
@@ -19,6 +21,7 @@ export const LoggedInButton = async () => {
|
|||||||
currentSession={currentSession.session}
|
currentSession={currentSession.session}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
|
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
|
||||||
|
apiEnabled={env.API_ENABLED}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { ChevronsUpDown } from "lucide-react";
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||||
import { LoggedInDropdown } from "./logged-in-dropdown";
|
import { LoggedInDropdown } from "./logged-in-dropdown";
|
||||||
import { Account, Session, User } from "better-auth";
|
import { Account, Session } from "better-auth";
|
||||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
type LoggedInButtonClientProps = {
|
type LoggedInButtonClientProps = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -13,12 +14,12 @@ type LoggedInButtonClientProps = {
|
|||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
providers: AuthProviderConfig[];
|
providers: AuthProviderConfig[];
|
||||||
|
apiEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers }: LoggedInButtonClientProps) => {
|
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled }: LoggedInButtonClientProps) => {
|
||||||
return (
|
return (
|
||||||
<LoggedInDropdown
|
<LoggedInDropdown
|
||||||
// @ts-ignore
|
|
||||||
user={user}
|
user={user}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -27,6 +28,7 @@ export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts,
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
|
apiEnabled={apiEnabled}
|
||||||
>
|
>
|
||||||
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
|
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PropsWithChildren, ReactNode, useState } from "react";
|
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { LogOut, User } from "lucide-react";
|
import { LogOut, User } from "lucide-react";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
import { signOut } from "@/lib/auth/auth-client";
|
import { signOut } from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
import { ProfileModal } from "@/features/layout/profile-modal";
|
import { ProfileModal } from "@/features/layout/profile-modal";
|
||||||
|
|
||||||
import { Account, Session, User as UserType } from "@/db/schema/02_user";
|
import { Account, Session, User as UserType } from "@/db/schema/02_user";
|
||||||
|
|
||||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
|
||||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||||
@@ -23,9 +16,10 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
|||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
providers: AuthProviderConfig[];
|
providers: AuthProviderConfig[];
|
||||||
|
apiEnabled: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled }: LoggedInDropdownProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -40,6 +34,7 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
|||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
|
apiEnabled={apiEnabled}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ type ProfileModalProps = {
|
|||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
providers: AuthProviderConfig[];
|
providers: AuthProviderConfig[];
|
||||||
|
apiEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled }: ProfileModalProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||||
@@ -54,7 +55,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||||
<ProfileAccount user={user} />
|
<ProfileAccount user={user} apiEnabled={apiEnabled} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="appearance" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
<TabsContent value="appearance" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
import { UserIcon, Settings, Palette, ShieldHalf, Workflow, KeyRound } from "lucide-react";
|
||||||
import { User } from "@/db/schema/02_user";
|
import { User } from "@/db/schema/02_user";
|
||||||
|
|
||||||
interface ProfileSidebarProps {
|
interface ProfileSidebarProps {
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {OrganizationForm} from "@/features/organizations/organization-form";
|
import {OrganizationForm} from "@/features/organizations/organization-form";
|
||||||
import {ReactNode, useState} from "react";
|
import {useState} from "react";
|
||||||
import {Button, buttonVariants} from "@/components/ui/button";
|
import {buttonVariants} from "@/components/ui/button";
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
import {GearIcon} from "@radix-ui/react-icons";
|
||||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
import {User} from "@/db/schema/02_user";
|
import {User} from "@/db/schema/02_user";
|
||||||
import {User as BetterAuthUser} from "better-auth";
|
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
type EditOrganizationDialogProps = {
|
type EditOrganizationDialogProps = {
|
||||||
organization: OrganizationWithMembers;
|
organization: OrganizationWithMembers;
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUser: BetterAuthUser;
|
currentUser: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditOrganizationDialog = ({
|
export const EditOrganizationDialog = ({
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ import {
|
|||||||
updateOrganizationAction
|
updateOrganizationAction
|
||||||
} from "@/features/organizations/organization.action";
|
} from "@/features/organizations/organization.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {User as BetterAuthUser} from "better-auth";
|
|
||||||
import {User} from "@/db/schema/02_user";
|
import {User} from "@/db/schema/02_user";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
export type organizationFormProps = {
|
export type organizationFormProps = {
|
||||||
defaultValues?: OrganizationWithMembers;
|
defaultValues?: OrganizationWithMembers;
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUser: BetterAuthUser;
|
currentUser: User;
|
||||||
onSuccess?: (data: any) => void;
|
onSuccess?: (data: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Button} from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {Input} from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {AlertCircle, Loader2} from "lucide-react";
|
import {
|
||||||
import {useMutation} from "@tanstack/react-query";
|
AlertCircle,
|
||||||
import {toast} from "sonner";
|
Check,
|
||||||
import {useRouter} from "next/navigation";
|
Copy,
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
KeyRound,
|
||||||
import {User} from "@/db/schema/02_user";
|
Loader2,
|
||||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
Plus,
|
||||||
import {EmailSchema, EmailSchemaType} from "./account.schema";
|
Trash2,
|
||||||
import {BetterAuthError} from "@/types/auth";
|
} from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import { User } from "@/db/schema/02_user";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useZodForm,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { EmailSchema, EmailSchemaType } from "./account.schema";
|
||||||
|
import { BetterAuthError } from "@/types/auth";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { timeAgo } from "@/utils/date-formatting";
|
||||||
|
import {
|
||||||
|
createApiKeysAction,
|
||||||
|
deleteApiKeyAction,
|
||||||
|
getApiKeysAction,
|
||||||
|
} from "@/features/profile/profile.action";
|
||||||
|
import { copyToClipboardWithMeta } from "@/components/common/copy-button";
|
||||||
|
|
||||||
interface ProfileAccountProps {
|
interface ProfileAccountProps {
|
||||||
user: User;
|
user: User;
|
||||||
|
apiEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileAccount({user}: ProfileAccountProps) {
|
export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false);
|
||||||
|
const [apiKeyName, setApiKeyName] = useState("");
|
||||||
|
|
||||||
|
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
|
||||||
|
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
||||||
|
|
||||||
const emailForm = useZodForm({
|
const emailForm = useZodForm({
|
||||||
schema: EmailSchema,
|
schema: EmailSchema,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -28,51 +68,64 @@ export function ProfileAccount({user}: ProfileAccountProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {mutate: updateEmail, isPending: isUpdatingEmail} = useMutation({
|
const { mutate: updateEmail, isPending: isUpdatingEmail } = useMutation({
|
||||||
mutationFn: async (values: EmailSchemaType) => {
|
mutationFn: async (values: EmailSchemaType) => {
|
||||||
const {error} = await authClient.changeEmail({
|
const { error } = await authClient.changeEmail({
|
||||||
newEmail: values.email,
|
newEmail: values.email,
|
||||||
callbackURL: window.location.href,
|
callbackURL: window.location.href,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return values.email;
|
return values.email;
|
||||||
},
|
},
|
||||||
onSuccess: (newEmail) => {
|
onSuccess: (newEmail) => {
|
||||||
toast.success("Email updated successfully.");
|
toast.success("Email updated successfully.");
|
||||||
emailForm.reset({email: newEmail});
|
|
||||||
|
emailForm.reset({ email: newEmail });
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: (error: BetterAuthError) => {
|
onError: (error: BetterAuthError) => {
|
||||||
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||||
toast.error("User already exists, use another email address!");
|
toast.error(
|
||||||
emailForm.reset({email: user.email});
|
"User already exists, use another email address!",
|
||||||
router.refresh()
|
);
|
||||||
|
|
||||||
|
emailForm.reset({ email: user.email });
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("An error occurred while trying to update your password!");
|
toast.error(
|
||||||
|
"An error occurred while trying to update your password!",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {mutate: resendVerificationEmail, isPending: isResendingVerification} = useMutation({
|
const {
|
||||||
|
mutate: resendVerificationEmail,
|
||||||
|
isPending: isResendingVerification,
|
||||||
|
} = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const currentEmailInput = emailForm.getValues("email");
|
const currentEmailInput = emailForm.getValues("email");
|
||||||
|
|
||||||
|
|
||||||
let error: BetterAuthError | null = null;
|
let error: BetterAuthError | null = null;
|
||||||
|
|
||||||
if (currentEmailInput === user.email) {
|
if (currentEmailInput === user.email) {
|
||||||
const sendVerification = await authClient.sendVerificationEmail({
|
const sendVerification =
|
||||||
email: currentEmailInput,
|
await authClient.sendVerificationEmail({
|
||||||
callbackURL: window.location.href,
|
email: currentEmailInput,
|
||||||
});
|
callbackURL: window.location.href,
|
||||||
|
});
|
||||||
|
|
||||||
error = sendVerification.error;
|
error = sendVerification.error;
|
||||||
} else {
|
} else {
|
||||||
const result = await authClient.changeEmail({
|
const result = await authClient.changeEmail({
|
||||||
callbackURL: window.location.href,
|
callbackURL: window.location.href,
|
||||||
newEmail: currentEmailInput,
|
newEmail: currentEmailInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
error = result.error;
|
error = result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,72 +134,410 @@ export function ProfileAccount({user}: ProfileAccountProps) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Verification email resent successfully.");
|
toast.success("Verification email resent successfully.");
|
||||||
},
|
},
|
||||||
onError: (_e: BetterAuthError) => {
|
onError: () => {
|
||||||
toast.error("Failed to resend verification email.");
|
toast.error("Failed to resend verification email.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: apikeys,
|
||||||
|
isLoading: isLoadingApiKeys,
|
||||||
|
refetch: refetchApiKeys,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["apikeys"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await getApiKeysAction();
|
||||||
|
|
||||||
|
if (result?.data?.success) {
|
||||||
|
return result.data.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
throw new Error("Failed to fetch API Keys");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: addApiKey, isPending: isAddingApikey } = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
|
||||||
|
const result = await createApiKeysAction({
|
||||||
|
name: apiKeyName || "My API Key"
|
||||||
|
});
|
||||||
|
console.log(result);
|
||||||
|
if (!result?.data?.success) {
|
||||||
|
throw new Error("Failed to create API Key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
setCreatedApiKey(result.data.value.key);
|
||||||
|
|
||||||
|
toast.success("API Key created successfully");
|
||||||
|
|
||||||
|
setIsAddApiKeyOpen(false);
|
||||||
|
setApiKeyName("");
|
||||||
|
|
||||||
|
refetchApiKeys();
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || "Failed to create API Key");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: revokeApiKey, isPending: isRevokingApiKey } =
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const result = await deleteApiKeyAction({ id });
|
||||||
|
|
||||||
|
if (!result?.data?.success) {
|
||||||
|
throw new Error("Failed to revoke API Key");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("API Key revoked successfully");
|
||||||
|
|
||||||
|
refetchApiKeys();
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to revoke API Key");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopyApiKey = async () => {
|
||||||
|
if (!createdApiKey) return;
|
||||||
|
|
||||||
|
await copyToClipboardWithMeta(createdApiKey);
|
||||||
|
|
||||||
|
setCopiedApiKey(true);
|
||||||
|
|
||||||
|
toast.success("API Key copied");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedApiKey(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
<>
|
||||||
<div className="mb-6 space-y-1">
|
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Account Settings</h2>
|
<div className="mb-6 space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Update your email and preferences.</p>
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
</div>
|
Account Settings
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Form form={emailForm} onSubmit={(values) => updateEmail(values)}>
|
Update your email and preferences.
|
||||||
<div className="grid gap-3">
|
</p>
|
||||||
<FormField
|
</div>
|
||||||
control={emailForm.control}
|
|
||||||
name="email"
|
|
||||||
render={({field}) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email Address</FormLabel>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder="Your email address"/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-3">
|
<div className="space-y-4">
|
||||||
<Button type="submit" variant="secondary"
|
<Form
|
||||||
disabled={isUpdatingEmail || !emailForm.formState.isDirty}>
|
form={emailForm}
|
||||||
{isUpdatingEmail &&
|
onSubmit={(values) => updateEmail(values)}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
>
|
||||||
{"Update"}
|
<div className="grid gap-3">
|
||||||
</Button>
|
<FormField
|
||||||
|
control={emailForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Email Address
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
{!user.emailVerified && (
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Your email address"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => resendVerificationEmail()}
|
disabled={
|
||||||
disabled={isResendingVerification || emailForm.formState.errors.email !== undefined}
|
isUpdatingEmail ||
|
||||||
|
!emailForm.formState
|
||||||
|
.isDirty
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isResendingVerification &&
|
{isUpdatingEmail && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{"Resend Verification"}
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormMessage/>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!user.emailVerified && (
|
Update
|
||||||
<div
|
</Button>
|
||||||
className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
|
|
||||||
<AlertCircle className="w-4 h-4"/>
|
{!user.emailVerified && (
|
||||||
<span>Your email is not verified. Please check your inbox.</span>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
resendVerificationEmail()
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isResendingVerification ||
|
||||||
|
emailForm
|
||||||
|
.formState
|
||||||
|
.errors
|
||||||
|
.email !==
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isResendingVerification && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
Resend Verification
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!user.emailVerified && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Your email is not verified. Please check
|
||||||
|
your inbox.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apiEnabled === true && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-medium">
|
||||||
|
API Keys
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Manage your personal access tokens for API
|
||||||
|
authentication
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isAddApiKeyOpen}
|
||||||
|
onOpenChange={setIsAddApiKeyOpen}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add API Key
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Add New API Key
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Give a name to your API Key to identify
|
||||||
|
it later.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
Key Name
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g. flow-1, LLM, Backend"
|
||||||
|
value={apiKeyName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setApiKeyName(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setIsAddApiKeyOpen(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => addApiKey()}
|
||||||
|
disabled={isAddingApikey}
|
||||||
|
>
|
||||||
|
{isAddingApikey && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
Create API Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{isLoadingApiKeys ? (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : apikeys && apikeys.length > 0 ? (
|
||||||
|
apikeys.map((ak: any) => (
|
||||||
|
<ApiKeyRow
|
||||||
|
key={ak.id}
|
||||||
|
apikey={ak}
|
||||||
|
onRevoke={(id) => revokeApiKey(id)}
|
||||||
|
isRevoking={isRevokingApiKey}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No API Key found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<Dialog
|
||||||
|
open={!!createdApiKey}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCreatedApiKey(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Your API Key</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This API Key will only be displayed once.
|
||||||
|
<br />
|
||||||
|
Copy it now before closing this dialog.
|
||||||
|
<br />
|
||||||
|
For security reasons, it cannot be viewed again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={createdApiKey || ""}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
>
|
||||||
|
{copiedApiKey ? (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
Store this API Key securely. You will not be able
|
||||||
|
to see it again after closing this dialog.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCreatedApiKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I copied my API Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ApiKeyRow({
|
||||||
|
apikey,
|
||||||
|
onRevoke,
|
||||||
|
isRevoking,
|
||||||
|
}: {
|
||||||
|
apikey: any;
|
||||||
|
onRevoke: (id: string) => void;
|
||||||
|
isRevoking: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
|
<KeyRound className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{apikey.name || "Unnamed API Key"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apikey?.start && apikey?.prefix ? (
|
||||||
|
<div className="text-xs font-mono text-muted-foreground">
|
||||||
|
{apikey.start}••••••••
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Created {timeAgo(new Date(apikey.createdAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onRevoke(apikey.id)}
|
||||||
|
disabled={isRevoking}
|
||||||
|
>
|
||||||
|
{isRevoking ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import { eq } from "drizzle-orm";
|
|||||||
import { ServerActionResult } from "@/types/action-type";
|
import { ServerActionResult } from "@/types/action-type";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import {auth, createApiKey, deleteApiKey, getApiKeys, getPasskeys, revokePasskey} from "@/lib/auth/auth";
|
||||||
import { user } from "@/db/schema/02_user";
|
import { user } from "@/db/schema/02_user";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {ApiKey} from "@better-auth/api-key";
|
||||||
|
|
||||||
const UpdateProfileSchema = z.object({
|
const UpdateProfileSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -53,3 +54,76 @@ export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const getApiKeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
|
||||||
|
try {
|
||||||
|
const apikeys = await getApiKeys();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: apikeys.apiKeys || [],
|
||||||
|
actionSuccess: {
|
||||||
|
message: "apikeys_fetched",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_fetching_apikeys",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateApiKeySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const createApiKeysAction = userAction.schema(CreateApiKeySchema).action(async ({parsedInput} ): Promise<ServerActionResult<ApiKey>> => {
|
||||||
|
try {
|
||||||
|
const apikey = await createApiKey(parsedInput.name);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: apikey,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "apikey_created",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_creating_apikeys",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const DeleteApiKeySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteApiKeyAction = userAction.schema(DeleteApiKeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||||
|
try {
|
||||||
|
await deleteApiKey(parsedInput.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: {},
|
||||||
|
actionSuccess: {
|
||||||
|
message: "apikey_revoked",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_deleting_apikey",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Moon, Sun, Check, SunMoon } from "lucide-react"
|
import { Moon, Sun, Check, SunMoon } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { authClient } from "@/lib/auth/auth-client"
|
import { authClient } from "@/lib/auth/auth-client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -20,6 +21,8 @@ const themes = [
|
|||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
const handleThemeChange = async (newTheme: "light" | "system" | "dark") => {
|
const handleThemeChange = async (newTheme: "light" | "system" | "dark") => {
|
||||||
await authClient.updateUser({ theme: newTheme })
|
await authClient.updateUser({ theme: newTheme })
|
||||||
@@ -29,7 +32,7 @@ export function ModeToggle() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-8 rounded-full border border-input bg-transparent shadow-xs transition-transform active:scale-95">
|
<Button variant="ghost" size="icon" className="size-8 rounded-full border border-input bg-transparent shadow-xs transition-transform active:scale-95">
|
||||||
{theme === "light" ? <Sun className="size-4" /> : theme === "dark" ? <Moon className="size-4" /> : <SunMoon className="size-4" />}
|
{!mounted || theme === "system" ? <SunMoon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
|
||||||
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
export const useOrganizationPermissions = (
|
||||||
|
activeMember: MemberWithUser | null,
|
||||||
|
) => {
|
||||||
|
return computeOrganizationPermissions(activeMember);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { computeSystemPermissions } from "@/lib/acl/system-acl";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
|
export const useSystemPermissions = (user: User | null) => {
|
||||||
|
return computeSystemPermissions(user);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
import {OrganizationRole} from "@/lib/acl/role";
|
||||||
|
|
||||||
|
|
||||||
export type OrganizationPermissions = {
|
export type OrganizationPermissions = {
|
||||||
role: string | null;
|
role: OrganizationRole | null;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
@@ -19,7 +20,7 @@ export type OrganizationPermissions = {
|
|||||||
export const computeOrganizationPermissions = (
|
export const computeOrganizationPermissions = (
|
||||||
activeMember: MemberWithUser | null
|
activeMember: MemberWithUser | null
|
||||||
): OrganizationPermissions => {
|
): OrganizationPermissions => {
|
||||||
const role = activeMember?.role ?? null;
|
const role = (activeMember?.role as OrganizationRole) ?? null;
|
||||||
|
|
||||||
const isOwner = role === "owner";
|
const isOwner = role === "owner";
|
||||||
const isAdmin = role === "admin";
|
const isAdmin = role === "admin";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type SystemRole = "superadmin" | "admin" | "user";
|
||||||
|
export type OrganizationRole = "owner" | "admin" | "member";
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { SystemRole } from "@/lib/acl/role";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
|
export type SystemPermissions = {
|
||||||
|
role: SystemRole | null;
|
||||||
|
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isUser: boolean;
|
||||||
|
|
||||||
|
canAccessSystem: boolean;
|
||||||
|
|
||||||
|
canCreateUser: boolean;
|
||||||
|
canUpdateUser: boolean;
|
||||||
|
canDeleteUser: boolean;
|
||||||
|
|
||||||
|
canAssignSuperAdmin: boolean;
|
||||||
|
canAssignAdmin: boolean;
|
||||||
|
canAssignUser: boolean;
|
||||||
|
|
||||||
|
canCreateOrganization: boolean;
|
||||||
|
canDeleteOrganization: boolean;
|
||||||
|
canUpdateOrganization: boolean;
|
||||||
|
|
||||||
|
canManageOrganizationUsers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeSystemPermissions = (
|
||||||
|
user: User | null,
|
||||||
|
): SystemPermissions => {
|
||||||
|
const role = (user?.role as SystemRole) ?? null;
|
||||||
|
|
||||||
|
const isSuperAdmin = role === "superadmin";
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
const isUser = role === "user";
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
|
||||||
|
isSuperAdmin,
|
||||||
|
isAdmin,
|
||||||
|
isUser,
|
||||||
|
|
||||||
|
canAccessSystem: isSuperAdmin,
|
||||||
|
|
||||||
|
canCreateUser: isSuperAdmin || isAdmin,
|
||||||
|
canUpdateUser: isSuperAdmin || isAdmin,
|
||||||
|
canDeleteUser: isSuperAdmin || isAdmin,
|
||||||
|
|
||||||
|
canAssignSuperAdmin: isSuperAdmin,
|
||||||
|
canAssignAdmin: isSuperAdmin || isAdmin,
|
||||||
|
canAssignUser: isSuperAdmin || isAdmin,
|
||||||
|
|
||||||
|
canCreateOrganization: isSuperAdmin,
|
||||||
|
canDeleteOrganization: isSuperAdmin,
|
||||||
|
canUpdateOrganization: isSuperAdmin || isAdmin,
|
||||||
|
|
||||||
|
canManageOrganizationUsers: isSuperAdmin || isAdmin,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth/auth";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {computeSystemPermissions} from "@/lib/acl/system-acl";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api-v1/middleware" });
|
||||||
|
|
||||||
|
type ApiKeyHandler = (
|
||||||
|
req: Request,
|
||||||
|
ctx: ApiKeyContext,
|
||||||
|
params?: Record<string, string>
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export function withApiKey(handler: ApiKeyHandler) {
|
||||||
|
return async (
|
||||||
|
req: Request,
|
||||||
|
context?: { params?: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const key = req.headers.get("x-api-key");
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing API key" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore — verifyApiKey is added by the @better-auth/api-key plugin
|
||||||
|
const result = await auth.api.verifyApiKey({ body: { key, configId: "standard" } });
|
||||||
|
|
||||||
|
if (!result?.valid || !result?.key) {
|
||||||
|
|
||||||
|
if (result.error?.code === "RATE_LIMITED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: result.error.message, details: (result.error as any).details ?? null },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired API key" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = result.key.referenceId as string;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired API key" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await drizzleDb.db.query.member.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.member.userId, userId),
|
||||||
|
with: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizations = await Promise.all(
|
||||||
|
memberships.map(async (m) => {
|
||||||
|
return {
|
||||||
|
id: m.organizationId,
|
||||||
|
permissions: computeOrganizationPermissions(m),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const userFetched = await db.query.user.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.user.id, userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if (!userFetched) {
|
||||||
|
throw new Error("Unable to find user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userFetched.banned) {
|
||||||
|
return NextResponse.json({ error: "Account suspended" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userFetched.role === "pending") {
|
||||||
|
return NextResponse.json({ error: "Account pending approval" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPermissions = computeSystemPermissions(userFetched)
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: userFetched.id,
|
||||||
|
permissions: userPermissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedParams = context?.params ? await context.params : {};
|
||||||
|
|
||||||
|
return handler(req, { user, organizations }, resolvedParams);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ error: err }, "Error in withApiKey middleware");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
extendZodWithOpenApi(z);
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import "@/lib/api-v1/openapi/registry";
|
||||||
|
import {AgentSchema} from "@/features/agents/agents.schema";
|
||||||
|
import {agentSchema} from "@/db/schema/08_agent";
|
||||||
|
|
||||||
|
const UuidParam = z
|
||||||
|
.string()
|
||||||
|
.uuid()
|
||||||
|
.openapi({ example: "123e4567-e89b-12d3-a456-426614174000" });
|
||||||
|
|
||||||
|
const security = [{ apiKeyAuth: [] }];
|
||||||
|
const tags = ["Agents"];
|
||||||
|
|
||||||
|
const ErrorSchema = z.object({ error: z.string() });
|
||||||
|
|
||||||
|
export function registerAgentRoutes(registry: OpenAPIRegistry) {
|
||||||
|
registry.register("Agent", z.object(agentSchema.shape).openapi("Agent"));
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/agents",
|
||||||
|
tags,
|
||||||
|
summary:"List agents",
|
||||||
|
security,
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of accessible agents",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ data: z.array(agentSchema) }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/agents",
|
||||||
|
tags,
|
||||||
|
summary:"Create an agent",
|
||||||
|
security,
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
name: z.string().min(1).openapi({ example: "my-agent" }),
|
||||||
|
organizationId: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "Agent created",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: z.object({ data: AgentSchema }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: "Bad request",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden — organization not accessible",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
description: "Invalid request body",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/agents/{id}",
|
||||||
|
tags,
|
||||||
|
summary:"Get agent by ID",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Agent details",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: z.object({ data: AgentSchema }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Agent not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/agents/{id}",
|
||||||
|
tags,
|
||||||
|
summary:"Delete agent",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
204: { description: "Agent deleted" },
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Agent not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/agents/{id}/key",
|
||||||
|
tags,
|
||||||
|
summary:"Get agent edge key",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Agent edge key string",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: z.object({ data: z.string() }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Agent not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import "@/lib/api-v1/openapi/registry";
|
||||||
|
import { databaseSchema, backupSchema, restorationSchema } from "@/db/schema/07_database";
|
||||||
|
import { backupStorageSchema } from "@/db/schema/14_storage-backup";
|
||||||
|
|
||||||
|
const datetimeNullable = z.string().datetime().nullable();
|
||||||
|
const datetime = z.string().datetime();
|
||||||
|
const commonTimestamps = {
|
||||||
|
createdAt: datetime,
|
||||||
|
updatedAt: datetimeNullable,
|
||||||
|
deletedAt: datetimeNullable,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DatabaseSchema = z
|
||||||
|
.object({
|
||||||
|
...databaseSchema.shape,
|
||||||
|
lastContact: datetimeNullable,
|
||||||
|
...commonTimestamps,
|
||||||
|
})
|
||||||
|
.openapi("Database");
|
||||||
|
|
||||||
|
const BackupStorageSchema = z
|
||||||
|
.object({
|
||||||
|
...backupStorageSchema.shape,
|
||||||
|
...commonTimestamps,
|
||||||
|
})
|
||||||
|
.openapi("BackupStorage");
|
||||||
|
|
||||||
|
const BackupSchema = z
|
||||||
|
.object({
|
||||||
|
...backupSchema.shape,
|
||||||
|
...commonTimestamps,
|
||||||
|
})
|
||||||
|
.openapi("Backup");
|
||||||
|
|
||||||
|
const BackupWithStoragesSchema = BackupSchema.extend({
|
||||||
|
storages: z.array(BackupStorageSchema),
|
||||||
|
}).openapi("BackupWithStorages");
|
||||||
|
|
||||||
|
const RestorationSchema = z
|
||||||
|
.object({
|
||||||
|
...restorationSchema.shape,
|
||||||
|
...commonTimestamps,
|
||||||
|
})
|
||||||
|
.openapi("Restoration");
|
||||||
|
|
||||||
|
const UuidParam = z
|
||||||
|
.string()
|
||||||
|
.uuid()
|
||||||
|
.openapi({ example: "123e4567-e89b-12d3-a456-426614174000" });
|
||||||
|
|
||||||
|
const security = [{ apiKeyAuth: [] }];
|
||||||
|
const tags = ["Databases"];
|
||||||
|
|
||||||
|
const ErrorSchema = z.object({ error: z.string() });
|
||||||
|
|
||||||
|
export function registerDatabaseRoutes(registry: OpenAPIRegistry) {
|
||||||
|
registry.register("Database", DatabaseSchema);
|
||||||
|
registry.register("Backup", BackupSchema);
|
||||||
|
registry.register("BackupStorage", BackupStorageSchema);
|
||||||
|
registry.register("BackupWithStorages", BackupWithStoragesSchema);
|
||||||
|
registry.register("Restoration", RestorationSchema);
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/databases",
|
||||||
|
tags,
|
||||||
|
summary: "List databases",
|
||||||
|
security,
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of accessible databases",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ data: z.array(DatabaseSchema) }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/databases/{id}",
|
||||||
|
tags,
|
||||||
|
summary: "Get database by ID",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Database details",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ data: DatabaseSchema }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Database not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/databases/{id}/status",
|
||||||
|
tags,
|
||||||
|
summary: "Get database status",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Database status with latest backup and restoration",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.object({
|
||||||
|
isWaitingForBackup: z.boolean().nullable(),
|
||||||
|
lastContact: datetimeNullable,
|
||||||
|
latestBackup: BackupSchema.nullable(),
|
||||||
|
latestRestoration: RestorationSchema.nullable(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Database not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/databases/{id}/backup",
|
||||||
|
tags,
|
||||||
|
summary: "List backups for a database",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of backups ordered by creation date descending",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ data: z.array(BackupSchema) }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Database not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/databases/{id}/backup",
|
||||||
|
tags,
|
||||||
|
summary: "Trigger a backup for a database",
|
||||||
|
security,
|
||||||
|
request: { params: z.object({ id: UuidParam }) },
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "Backup job created with status 'waiting'",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: z.object({ data: BackupSchema }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Database not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: "A backup is already waiting or ongoing for this database",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/databases/{id}/backup/{backupId}",
|
||||||
|
tags,
|
||||||
|
summary: "Get a specific backup with storage details",
|
||||||
|
security,
|
||||||
|
request: {
|
||||||
|
params: z.object({ id: UuidParam, backupId: UuidParam }),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Backup with associated storage records",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ data: BackupWithStoragesSchema }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Database or backup not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/databases/{id}/restore",
|
||||||
|
tags,
|
||||||
|
summary: "Restore a database from a backup",
|
||||||
|
security,
|
||||||
|
request: {
|
||||||
|
params: z.object({ id: UuidParam }),
|
||||||
|
body: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
backupId: z.string().uuid(),
|
||||||
|
backupStorageId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "Restoration job created with status 'waiting'",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ data: RestorationSchema }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Missing or invalid API key",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Database, backup, or backup storage not found",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description:
|
||||||
|
"A restoration is already waiting or ongoing for this database",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
description:
|
||||||
|
"Invalid request body, or backup storage is not in 'success' state",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: { "application/json": { schema: ErrorSchema } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
||||||
|
|
||||||
|
export function registerSecuritySchemes(registry: OpenAPIRegistry) {
|
||||||
|
registry.registerComponent("securitySchemes", "apiKeyAuth", {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "header",
|
||||||
|
name: "x-api-key",
|
||||||
|
description:
|
||||||
|
"API key generated from the Portabase dashboard. Pass as the x-api-key header.",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import "@/lib/api-v1/openapi/registry";
|
||||||
|
import { registerSecuritySchemes } from "@/lib/api-v1/openapi/security";
|
||||||
|
import { registerAgentRoutes } from "@/lib/api-v1/openapi/routes/agents";
|
||||||
|
import { registerDatabaseRoutes } from "@/lib/api-v1/openapi/routes/databases";
|
||||||
|
|
||||||
|
export function buildSpec() {
|
||||||
|
const registry = new OpenAPIRegistry();
|
||||||
|
|
||||||
|
registerSecuritySchemes(registry);
|
||||||
|
registerAgentRoutes(registry);
|
||||||
|
registerDatabaseRoutes(registry);
|
||||||
|
|
||||||
|
return new OpenApiGeneratorV3(registry.definitions).generateDocument({
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: {
|
||||||
|
title: "Portabase API",
|
||||||
|
version: "1.0.0",
|
||||||
|
description:
|
||||||
|
"Authenticate all requests using the x-api-key header with an API key generated from the Portabase dashboard.",
|
||||||
|
},
|
||||||
|
servers: [{ url: "/api/v1" }],
|
||||||
|
security: [{ apiKeyAuth: [] }],
|
||||||
|
tags: [
|
||||||
|
{ name: "Agents", description: "Agent management" },
|
||||||
|
{ name: "Databases", description: "Database management and backup operations" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { eq, inArray, and, or, isNull } from "drizzle-orm";
|
||||||
|
import {ApiKeyContextUser} from "@/lib/api-v1/types";
|
||||||
|
import {AgentWith} from "@/db/schema/08_agent";
|
||||||
|
|
||||||
|
export async function getAccessibleAgentIds(
|
||||||
|
user: ApiKeyContextUser
|
||||||
|
): Promise<string[]> {
|
||||||
|
|
||||||
|
const memberships = await db.query.member.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.member.userId, user.id),
|
||||||
|
or(
|
||||||
|
eq(drizzleDb.schemas.member.role, "admin"),
|
||||||
|
eq(drizzleDb.schemas.member.role, "owner")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
columns: { organizationId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgIds = memberships.map((m) => m.organizationId);
|
||||||
|
|
||||||
|
if (orgIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActiveAgent = or(
|
||||||
|
eq(drizzleDb.schemas.agent.isArchived, false),
|
||||||
|
isNull(drizzleDb.schemas.agent.isArchived)
|
||||||
|
);
|
||||||
|
|
||||||
|
const orgAgents = await db.query.organizationAgent.findMany({
|
||||||
|
where: inArray(
|
||||||
|
drizzleDb.schemas.organizationAgent.organizationId,
|
||||||
|
orgIds
|
||||||
|
),
|
||||||
|
columns: { agentId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const junctionAgentIds = orgAgents.map((oa) => oa.agentId);
|
||||||
|
|
||||||
|
let directAgentIds: string[] = [];
|
||||||
|
|
||||||
|
if (user.permissions.isAdmin || user.permissions.isSuperAdmin) {
|
||||||
|
const directAgents = await db.query.agent.findMany({
|
||||||
|
where: isActiveAgent,
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
directAgentIds = directAgents.map((a) => a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAgentIds = [
|
||||||
|
...new Set([
|
||||||
|
...junctionAgentIds,
|
||||||
|
...directAgentIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allAgentIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await db.query.agent.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(drizzleDb.schemas.agent.id, allAgentIds),
|
||||||
|
isActiveAgent
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return agents.map((a) => a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAgentAccess(id: string, user: ApiKeyContextUser) {
|
||||||
|
const accessibleAgentIds = await getAccessibleAgentIds(user);
|
||||||
|
if (accessibleAgentIds.includes(id)) return "ok";
|
||||||
|
|
||||||
|
const exists = await db.query.agent.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.agent.id, id),
|
||||||
|
or(
|
||||||
|
eq(drizzleDb.schemas.agent.isArchived, false),
|
||||||
|
isNull(drizzleDb.schemas.agent.deletedAt)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return exists ? "forbidden" : "not_found";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type GetAgentOptions = {
|
||||||
|
includeDatabases?: boolean;
|
||||||
|
includeOrganizations?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAgent(
|
||||||
|
id: string,
|
||||||
|
options: GetAgentOptions = {}
|
||||||
|
) {
|
||||||
|
const agent = drizzleDb.schemas.agent;
|
||||||
|
|
||||||
|
const withRelations: {
|
||||||
|
databases?: true;
|
||||||
|
organizations?: true;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (options.includeDatabases) {
|
||||||
|
withRelations.databases = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeOrganizations) {
|
||||||
|
withRelations.organizations = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.query.agent.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(agent.id, id),
|
||||||
|
eq(agent.isArchived, false),
|
||||||
|
isNull(agent.deletedAt)
|
||||||
|
),
|
||||||
|
...(Object.keys(withRelations).length > 0
|
||||||
|
? { with: withRelations }
|
||||||
|
: {}),
|
||||||
|
}) as unknown as AgentWith;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {ApiKeyContextUser} from "@/lib/api-v1/types";
|
||||||
|
import {getAccessibleDatabaseIds} from "@/lib/api-v1/services/databases";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
|
export async function resolveDatabaseAccess(id: string, user: ApiKeyContextUser) {
|
||||||
|
const accessibleIds = await getAccessibleDatabaseIds(user);
|
||||||
|
if (accessibleIds.includes(id)) return "ok";
|
||||||
|
const exists = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.id, id),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
return exists ? "forbidden" : "not_found";
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { eq, inArray, and, isNull } from "drizzle-orm";
|
||||||
|
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
|
||||||
|
import {ApiKeyContext, ApiKeyContextUser} from "@/lib/api-v1/types";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
|
|
||||||
|
export async function getAccessibleDatabaseIds(user: ApiKeyContextUser): Promise<string[]> {
|
||||||
|
const agentIds = await getAccessibleAgentIds(user);
|
||||||
|
if (agentIds.length === 0) return [];
|
||||||
|
|
||||||
|
const databases = await db.query.database.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(drizzleDb.schemas.database.agentId, agentIds),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return databases.map((d) => d.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessibleDatabases(user: ApiKeyContext["user"]) {
|
||||||
|
const agentIds = await getAccessibleAgentIds(user);
|
||||||
|
|
||||||
|
if (agentIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.query.database.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(drizzleDb.schemas.database.agentId, agentIds),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type GuardResult<T> =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
response: NextResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsonError(message: string, status: number) {
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireDatabaseAccess(
|
||||||
|
params: Record<string, string> | undefined,
|
||||||
|
user: ApiKeyContext["user"]
|
||||||
|
): Promise<GuardResult<{ id: string }>> {
|
||||||
|
const id = params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: jsonError("Not found", 404),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await resolveDatabaseAccess(id, user);
|
||||||
|
|
||||||
|
if (access === "ok") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: { id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (access === "forbidden") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: jsonError("Forbidden", 403),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: jsonError("Not found", 404),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatabaseAccessResult = "ok" | "forbidden" | "not_found";
|
||||||
|
|
||||||
|
export async function resolveDatabaseAccess(
|
||||||
|
id: string,
|
||||||
|
user: ApiKeyContext["user"]
|
||||||
|
): Promise<DatabaseAccessResult> {
|
||||||
|
const accessibleIds = await getAccessibleDatabaseIds(user);
|
||||||
|
|
||||||
|
if (accessibleIds.includes(id)) {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await db.query.database.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, id),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return exists ? "forbidden" : "not_found";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {SystemPermissions} from "@/lib/acl/system-acl";
|
||||||
|
import {OrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||||
|
|
||||||
|
export type ApiKeyContextUser = {
|
||||||
|
id: string;
|
||||||
|
permissions: SystemPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyContextOrganizations = {
|
||||||
|
id: string;
|
||||||
|
permissions: OrganizationPermissions
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type ApiKeyContext = {
|
||||||
|
user: ApiKeyContextUser;
|
||||||
|
organizations: ApiKeyContextOrganizations
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type ParseJsonBodyResult<T> =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
response: NextResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function parseJsonBody<TSchema extends z.ZodTypeAny>(
|
||||||
|
req: Request,
|
||||||
|
schema: TSchema
|
||||||
|
): Promise<ParseJsonBodyResult<z.infer<TSchema>>> {
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json(
|
||||||
|
{ error: "Invalid JSON body" },
|
||||||
|
{ status: 422 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json(
|
||||||
|
{
|
||||||
|
error: parsed.error.issues[0]?.message ?? "Invalid body",
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: parsed.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import {createAuthClient} from "better-auth/react";
|
import {createAuthClient} from "better-auth/react";
|
||||||
|
|
||||||
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
|
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
|
||||||
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
|
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
|
||||||
import type {auth} from "@/lib/auth/auth";
|
import type {auth} from "@/lib/auth/auth";
|
||||||
import {getServerUrl} from "@/utils/get-server-url";
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
import { ssoClient } from "@better-auth/sso/client";
|
import { ssoClient } from "@better-auth/sso/client";
|
||||||
import { passkeyClient } from "@better-auth/passkey/client"
|
import { passkeyClient } from "@better-auth/passkey/client"
|
||||||
|
import {apiKeyClient} from "@better-auth/api-key/client";
|
||||||
|
|
||||||
const res = await fetch(`${getServerUrl()}/api/config`);
|
const res = await fetch(`${getServerUrl()}/api/config`);
|
||||||
const {PROJECT_URL} = await res.json();
|
const {PROJECT_URL} = await res.json();
|
||||||
@@ -14,6 +14,7 @@ const {PROJECT_URL} = await res.json();
|
|||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: PROJECT_URL,
|
baseURL: PROJECT_URL,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
apiKeyClient(),
|
||||||
passkeyClient(),
|
passkeyClient(),
|
||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
ssoClient(),
|
ssoClient(),
|
||||||
|
|||||||
+66
-55
@@ -22,7 +22,8 @@ import {passkey} from "@better-auth/passkey";
|
|||||||
import {getOidcProviders} from "./oidc";
|
import {getOidcProviders} from "./oidc";
|
||||||
import {APIError} from "better-auth/api";
|
import {APIError} from "better-auth/api";
|
||||||
import {getOAuthProviders} from "./oauth";
|
import {getOAuthProviders} from "./oauth";
|
||||||
import { logger } from "@/lib/logger";
|
import {logger} from "@/lib/logger";
|
||||||
|
import {apiKey} from "@better-auth/api-key"
|
||||||
|
|
||||||
const log = logger.child({ module: "lib/auth" });
|
const log = logger.child({ module: "lib/auth" });
|
||||||
|
|
||||||
@@ -130,8 +131,42 @@ export const auth = betterAuth({
|
|||||||
allowDifferentEmails: true,
|
allowDifferentEmails: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
|
...(env.API_ENABLED
|
||||||
|
? [
|
||||||
|
apiKey([
|
||||||
|
{
|
||||||
|
configId: "public",
|
||||||
|
defaultPrefix: "pk_",
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 100,
|
||||||
|
timeWindow: 1000 * 60 * 60, // 100 requests / hour
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configId: "standard",
|
||||||
|
defaultPrefix: "sk_",
|
||||||
|
enableMetadata: true,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 1000,
|
||||||
|
timeWindow: 1000 * 60 * 60, // 1000 requests / hour
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configId: "internal",
|
||||||
|
defaultPrefix: "int_",
|
||||||
|
enableMetadata: true,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 10_000,
|
||||||
|
timeWindow: 1000 * 60 * 60, // 10k requests / hour
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
sso({
|
sso({
|
||||||
defaultSSO: oidcProviders.map((p) => ({
|
defaultSSO: oidcProviders.map((p) => ({
|
||||||
oidcConfig: {
|
oidcConfig: {
|
||||||
@@ -259,7 +294,6 @@ export const auth = betterAuth({
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
openAPI(),
|
openAPI(),
|
||||||
nextCookies(),
|
|
||||||
twoFactor(),
|
twoFactor(),
|
||||||
organization({
|
organization({
|
||||||
ac,
|
ac,
|
||||||
@@ -280,6 +314,7 @@ export const auth = betterAuth({
|
|||||||
superadmin,
|
superadmin,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
nextCookies(),
|
||||||
],
|
],
|
||||||
advanced: {
|
advanced: {
|
||||||
database: {
|
database: {
|
||||||
@@ -498,35 +533,6 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/* databaseHooks: {
|
|
||||||
session: {
|
|
||||||
create: {
|
|
||||||
before: async (session) => {
|
|
||||||
const organizationId = await getLastOrganizationOrFirst(session.userId);
|
|
||||||
|
|
||||||
if (!organizationId) {
|
|
||||||
return {
|
|
||||||
...session,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const [aa] = await db
|
|
||||||
.update(drizzleUser.session)
|
|
||||||
.set({ activeOrganizationId: organizationId })
|
|
||||||
.where(eq(drizzleUser.session.id, session.id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
|
||||||
...session,
|
|
||||||
activeOrganizationId: organizationId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},*/
|
|
||||||
// trustedOrigins: [env.PROJECT_URL!, "http://app"],
|
|
||||||
trustedOrigins: async (request) => {
|
trustedOrigins: async (request) => {
|
||||||
const trustedOrigins = await queryTrustedDomains();
|
const trustedOrigins = await queryTrustedDomains();
|
||||||
return trustedOrigins;
|
return trustedOrigins;
|
||||||
@@ -547,29 +553,6 @@ const queryTrustedDomains = async (): Promise<string[]> => {
|
|||||||
return domains;
|
return domains;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*export const signUpUser = async (email: string, password: string, name: string) => {
|
|
||||||
const user = await auth.api.signUpEmail({
|
|
||||||
body: {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return user;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signInUser = async (email: string, password: string) => {
|
|
||||||
const user = await auth.api.signInEmail({
|
|
||||||
body: {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return user;
|
|
||||||
};*/
|
|
||||||
|
|
||||||
export const createUser = async (
|
export const createUser = async (
|
||||||
name: string,
|
name: string,
|
||||||
email: string,
|
email: string,
|
||||||
@@ -660,6 +643,34 @@ export const getOrganization = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getApiKeys = async () => {
|
||||||
|
return await auth.api.listApiKeys({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createApiKey = async (name: string) => {
|
||||||
|
return await auth.api.createApiKey({
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
configId: "standard",
|
||||||
|
},
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteApiKey = async (keyId: string) => {
|
||||||
|
await auth.api.deleteApiKey({
|
||||||
|
body: {
|
||||||
|
keyId: keyId,
|
||||||
|
configId: "standard",
|
||||||
|
},
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getPasskeys = async () => {
|
export const getPasskeys = async () => {
|
||||||
if (env.AUTH_PASSKEY_ENABLED !== "true") return [];
|
if (env.AUTH_PASSKEY_ENABLED !== "true") return [];
|
||||||
const passkeys = await auth.api.listPasskeys({
|
const passkeys = await auth.api.listPasskeys({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
export const currentUser = async () => {
|
export const currentUser = async () => {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
@@ -11,5 +12,5 @@ export const currentUser = async () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.user;
|
return session.user as User;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user