Compare commits

..

15 Commits

Author SHA1 Message Date
ZhenYi
587dc06e8c chore(api): regenerate TypeScript SDK with new user endpoints
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
New endpoints: GET /api/users/{username}/activity, GET /api/users/{username}/stars,
GET /api/users/{username}/following. Updated types: UserActivityItem, UserActivityResponse,
UserStarsResponse, RepoStarItem, ProjectFollowItem, UserCard.
2026-04-22 22:39:19 +08:00
ZhenYi
80e2201b8b feat(user): add Activity, Following, Stars, Security tabs to profile page
- Backend: user_activity service (user_activity_log + project_activity)
- Backend: stars service (repo_star + project_follow)
- Backend: user_get_following_list (with is_following_me mutual check)
- Frontend: Tab navigation on /user/{username} with Overview/Activity/Following/Stars/Security
- Frontend: UserActivity timeline, FollowingList grid, StarsList, SecurityTab (SSH keys + PATs)
2026-04-22 22:39:14 +08:00
ZhenYi
f67c788cbe feat(gRPC): migrate admin RPC from Redis Pub/Sub to Tonic gRPC
- libs/rpc/admin: tonic-prost generated server + client wrappers
- apps/adminrpc: standalone binary with all 8 admin RPC methods
- Redis Pub/Sub JSON-RPC code removed from admin module
- libs/agent: add React agent loop for ReAct pattern
- proto/admin.proto: updated with list_workspace_sessions, is_user_online
2026-04-22 22:39:06 +08:00
ZhenYi
850a5392ce fix(admin): use AdminGrpcClient instead of direct fetch, add model capability table
- Replace direct REST fetch with AdminGrpcClient for AI model/provider/pricing routes
- Add model_capability table to sync route
- AdminGrpcClient handles all admin RPC calls with workspace_id routing
2026-04-22 22:38:59 +08:00
ZhenYi
aef5280ae8 fix(projects): include project_members when listing user projects
Users who accepted a project invitation could not see that project
on their /user/{username} page because get_user_projects only queried
projects where created_by == user_uid, ignoring project_members entries.
Now unions created_projects and member_projects with privacy filtering.
2026-04-22 22:38:52 +08:00
ZhenYi
16b681c55b infra(admin): add ADMIN_RPC_URL env var and adminrpc to push script 2026-04-22 20:56:10 +08:00
ZhenYi
d193c6113d refactor(admin): env.ts formatting, default ADMIN_RPC_URL namespace fix 2026-04-22 20:55:06 +08:00
ZhenYi
b5cafb9678 fix(admin): touchSession persist state, platform session prefix to user:, middleware pass permissions header 2026-04-22 20:54:24 +08:00
ZhenYi
bf25b9ac71 feat(admin): add daily-report recipients GET/POST route 2026-04-22 20:54:03 +08:00
ZhenYi
da96cdd236 feat(admin): daily-report support custom basic_api_url for AI endpoint 2026-04-22 20:53:43 +08:00
ZhenYi
c41f4efc04 fix(admin): workspace_billing_history user column + rbac listUsers pagination params order 2026-04-22 20:53:22 +08:00
ZhenYi
623faf8c55 fix(admin): project_members use project_uuid/user_uuid column names not SeaORM field names 2026-04-22 20:53:02 +08:00
ZhenYi
4d4a0dc886 fix(admin): platform/users SELECT uid not id, UPDATE WHERE user not user_id 2026-04-22 20:52:39 +08:00
ZhenYi
0a02e14bda fix(admin): platform/ai missing await on 4 queries 2026-04-22 20:52:17 +08:00
ZhenYi
e6a5828d14 fix(admin): audit-logs count query params mismatch 2026-04-22 20:51:56 +08:00
57 changed files with 5442 additions and 909 deletions

View File

@ -80,6 +80,7 @@ admin:
COOKIE_SECURE: false
COOKIE_SAME_SITE: lax
APP_NEXTAUTH_SECRET: ""
ADMIN_RPC_URL: adminrpc.gitdataai.svc.cluster.local:3001
nodeSelector: { }

View File

@ -8,6 +8,8 @@
"name": "admin",
"version": "0.1.0",
"dependencies": {
"@bufbuild/connect": "^0.13.0",
"@bufbuild/protobuf": "^2.11.0",
"@types/node-cron": "^3.0.11",
"argon2": "^0.44.0",
"bcrypt": "^5.1.1",
@ -272,6 +274,22 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/connect": {
"version": "0.13.0",
"resolved": "https://registry.npmmirror.com/@bufbuild/connect/-/connect-0.13.0.tgz",
"integrity": "sha512-eZSMbVLyUFtXiZNORgCEvv580xKZeYQdMOWj2i/nxOcpXQcrEzTMTA7SZzWv4k4gveWCOSRoWmYDeOhfWXJv0g==",
"deprecated": "Connect has moved to its own org @connectrpc and has a stable v1. Run `npx @connectrpc/connect-migrate@latest` to update. See https://github.com/connectrpc/connect-es/releases/tag/v0.13.1 for details.",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^1.2.1"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"dev": true,

View File

@ -13,6 +13,8 @@
"test:ui": "playwright test --ui"
},
"dependencies": {
"@bufbuild/connect": "^0.13.0",
"@bufbuild/protobuf": "^2.11.0",
"@types/node-cron": "^3.0.11",
"argon2": "^0.44.0",
"bcrypt": "^5.1.1",

View File

@ -18,6 +18,7 @@ interface AiConfig {
ai_model?: string;
ai_api_key?: string;
ai_enabled?: string;
basic_api_url?: string;
smtp_host?: string;
smtp_port?: string;
smtp_username?: string;
@ -318,6 +319,15 @@ export default function DailyReportPage() {
onChange={e => setAiForm(f => ({ ...f, ai_api_key: e.target.value }))}
placeholder="sk-..." />
</div>
<div className="form-group">
<label className="form-label"> API </label>
<input className="form-input" value={aiForm.basic_api_url || ""}
onChange={e => setAiForm(f => ({ ...f, basic_api_url: e.target.value }))}
placeholder="https://api.openai.com留空使用默认地址" />
<span style={{ fontSize: "12px", color: "#737373" }}>
OpenAI Cloudflare AI GatewayOneAPI
</span>
</div>
</div>
{/* SMTP Settings */}

View File

@ -1,40 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
import { createModel, updateModel, deleteModel } from "@/lib/adminrpc/client";
export const runtime = "nodejs";
async function adminFetch(path: string, method: string, body?: unknown) {
if (!ADMIN_API_SHARED_KEY) {
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
}
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
method,
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const data = await res.json();
if (!res.ok) return NextResponse.json(data, { status: res.status });
return NextResponse.json(data);
}
// POST /api/admin/ai/models — create model
export async function POST(req: NextRequest) {
return adminFetch("/api/admin/ai/models", "POST", await req.json());
try {
const body = await req.json();
const data = await createModel(body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Create model error:", msg);
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
}
}
// PATCH /api/admin/ai/models?id={id} — update model
export async function PATCH(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return adminFetch(`/api/admin/ai/models/${id}`, "PATCH", await req.json());
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const body = await req.json();
const data = await updateModel(id, body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Update model error:", msg);
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
}
}
// DELETE /api/admin/ai/models?id={id} — delete model
export async function DELETE(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return adminFetch(`/api/admin/ai/models/${id}`, "DELETE");
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const data = await deleteModel(id);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Delete model error:", msg);
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
}
}

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
import { updatePricing } from "@/lib/adminrpc/client";
export const runtime = "nodejs";
@ -8,18 +8,14 @@ export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!ADMIN_API_SHARED_KEY) {
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
try {
const { id } = await params;
const body = await req.json();
const data = await updatePricing(id, body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Update pricing error:", msg);
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
}
const body = await req.json();
const res = await fetch(`${RUST_BACKEND_URL}/api/admin/ai/pricing/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const data = await res.json();
if (!res.ok) return NextResponse.json(data, { status: res.status });
return NextResponse.json(data);
}

View File

@ -1,40 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
import { createProvider, updateProvider, deleteProvider } from "@/lib/adminrpc/client";
export const runtime = "nodejs";
async function adminFetch(path: string, method: string, body?: unknown) {
if (!ADMIN_API_SHARED_KEY) {
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
}
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
method,
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const data = await res.json();
if (!res.ok) return NextResponse.json(data, { status: res.status });
return NextResponse.json(data);
}
// POST /api/admin/ai/providers — create provider
export async function POST(req: NextRequest) {
return adminFetch("/api/admin/ai/providers", "POST", await req.json());
try {
const body = await req.json();
const data = await createProvider(body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Create provider error:", msg);
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
}
}
// PATCH /api/admin/ai/providers?id={id} — update provider
export async function PATCH(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return adminFetch(`/api/admin/ai/providers/${id}`, "PATCH", await req.json());
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const body = await req.json();
const data = await updateProvider(id, body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Update provider error:", msg);
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
}
}
// DELETE /api/admin/ai/providers?id={id} — delete provider
export async function DELETE(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return adminFetch(`/api/admin/ai/providers/${id}`, "DELETE");
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const data = await deleteProvider(id);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Delete provider error:", msg);
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
}
}

View File

@ -1,40 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
import { createVersion, updateVersion, deleteVersion } from "@/lib/adminrpc/client";
export const runtime = "nodejs";
async function adminFetch(path: string, method: string, body?: unknown) {
if (!ADMIN_API_SHARED_KEY) {
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
}
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
method,
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const data = await res.json();
if (!res.ok) return NextResponse.json(data, { status: res.status });
return NextResponse.json(data);
}
// POST /api/admin/ai/versions — create version
export async function POST(req: NextRequest) {
return adminFetch("/api/admin/ai/versions", "POST", await req.json());
try {
const body = await req.json();
const data = await createVersion(body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Create version error:", msg);
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
}
}
// PATCH /api/admin/ai/versions?id={id} — update version
export async function PATCH(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return adminFetch(`/api/admin/ai/versions/${id}`, "PATCH", await req.json());
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const body = await req.json();
const data = await updateVersion(id, body);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Update version error:", msg);
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
}
}
// DELETE /api/admin/ai/versions?id={id} — delete version
export async function DELETE(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return adminFetch(`/api/admin/ai/versions/${id}`, "DELETE");
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const data = await deleteVersion(id);
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Delete version error:", msg);
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
}
}

View File

@ -56,7 +56,7 @@ export async function PUT(req: NextRequest) {
const body = await req.json() as Record<string, string>;
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
const allowedKeys = [
"ai_model", "ai_api_key", "ai_enabled",
"ai_model", "ai_api_key", "ai_enabled", "basic_api_url",
"smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_from", "smtp_tls",
"report_enabled",
];

View File

@ -50,6 +50,7 @@ interface AiConfig {
ai_model: string;
ai_api_key: string;
ai_enabled: string;
basic_api_url: string;
smtp_host: string;
smtp_port: string;
smtp_username: string;
@ -62,10 +63,11 @@ interface AiConfig {
// ─── Main handler ─────────────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
// Verify cron secret (optional, set in K8s CronJob annotation)
// Verify cron — accept internal marker OR secret from K8s CronJob
const cronInternal = req.headers.get("x-cron-internal");
const cronSecret = req.headers.get("x-cron-secret");
const expectedSecret = process.env.DAILY_REPORT_CRON_SECRET;
if (expectedSecret && cronSecret !== expectedSecret) {
if (!cronInternal && (!expectedSecret || cronSecret !== expectedSecret)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@ -101,7 +103,12 @@ export async function POST(req: NextRequest) {
// ── Generate AI summary ──────────────────────────────────────────────────
let aiSummary = "";
if (cfg.ai_enabled === "true" && cfg.ai_api_key) {
aiSummary = await generateAiSummary(stats, cfg.ai_model || "gpt-4o-mini", cfg.ai_api_key);
aiSummary = await generateAiSummary(
stats,
cfg.ai_model || "gpt-4o-mini",
cfg.ai_api_key,
cfg.basic_api_url || ""
);
}
// ── Build email content ─────────────────────────────────────────────────
@ -172,17 +179,17 @@ async function collectDailyStats(): Promise<DailyStats> {
),
// Active users today (users who sent messages)
query<{ count: string }>(
`SELECT COUNT(DISTINCT user_id)::text as count
`SELECT COUNT(DISTINCT sender_id)::text as count
FROM room_message WHERE created_at >= $1`,
[todayStr]
),
// Top room by message count today
query<{ room_id: string; room_name: string; message_count: string }>(
`SELECT rm.room_id, r.name as room_name, COUNT(*)::text as message_count
query<{ room: string; room_name: string; message_count: string }>(
`SELECT rm.room, r.name as room_name, COUNT(*)::text as message_count
FROM room_message rm
JOIN room r ON r.id = rm.room_id
JOIN room r ON r.id = rm.room
WHERE rm.created_at >= $1
GROUP BY rm.room_id, r.name
GROUP BY rm.room, r.name
ORDER BY COUNT(*) DESC
LIMIT 1`,
[todayStr]
@ -196,7 +203,7 @@ async function collectDailyStats(): Promise<DailyStats> {
activeUsers: parseInt(activeUserRow.rows[0]?.count || "0", 10),
newCommits: 0, // filled below
topRoom: topRoomRow.rows[0] ? {
id: topRoomRow.rows[0].room_id,
id: topRoomRow.rows[0].room,
name: topRoomRow.rows[0].room_name,
messageCount: parseInt(topRoomRow.rows[0].message_count || "0", 10),
} : null,
@ -221,7 +228,7 @@ async function collectDailyStats(): Promise<DailyStats> {
const messages = await query<{ content: string; created_at: string }>(
`SELECT content, created_at::text
FROM room_message
WHERE room_id = $1 AND created_at >= $2
WHERE room = $1 AND created_at >= $2
ORDER BY created_at DESC
LIMIT 20`,
[stats.topRoom.id, todayStr]
@ -240,7 +247,8 @@ async function collectDailyStats(): Promise<DailyStats> {
async function generateAiSummary(
stats: DailyStats,
model: string,
apiKey: string
apiKey: string,
basicApiUrl: string
): Promise<string> {
const systemPrompt = `你是一名平台运营分析师。请根据以下每日平台数据生成一段简洁的中文总结100-200字分析今日平台的关键变化和亮点。注意
1.
@ -275,8 +283,11 @@ async function generateAiSummary(
${topRoomContext}
${userMessagesSection}`;
const baseUrl = basicApiUrl || "https://api.openai.com";
const chatEndpoint = `${baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
const response = await fetch(chatEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db";
import { createAuditLog } from "@/lib/log";
export const runtime = "nodejs";
// GET /api/admin/daily-report/recipients — list all recipients
export async function GET() {
try {
const result = await query(
`SELECT id, email, name, is_active, created_at::text as created_at, created_by
FROM internal_email_recipients
ORDER BY created_at DESC`
);
return NextResponse.json({ recipients: result.rows });
} catch (e) {
console.error("[recipients] List error:", e);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}
// POST /api/admin/daily-report/recipients — add recipient
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { email?: string; name?: string };
const { email, name = "" } = body;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "无效的邮箱地址" }, { status: 400 });
}
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
const result = await query(
`INSERT INTO internal_email_recipients (email, name, created_by)
VALUES ($1, $2, $3)
RETURNING id, email, name, is_active, created_at::text as created_at`,
[email.toLowerCase(), name, adminUserId]
);
await createAuditLog({
userId: adminUserId,
username: req.headers.get("x-admin-username") || "unknown",
action: "create",
resource: "internal_email_recipient",
resourceId: String(result.rows[0]?.id),
requestParams: { email },
});
return NextResponse.json(result.rows[0], { status: 201 });
} catch (e: unknown) {
const err = e as { code?: string };
if (err?.code === "23505") {
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
}
console.error("[recipients] Add error:", e);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}

View File

@ -20,8 +20,8 @@ export async function PATCH(
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
}
const member = await query<{ id: string; scope: string; user: string }>(
`SELECT id, scope, user FROM project_members WHERE id = $1 AND project = $2`,
const member = await query<{ id: string; scope: string; user_uuid: string }>(
`SELECT id, scope, user_uuid FROM project_members WHERE id = $1 AND project_uuid = $2`,
[memberId, id]
);
if (!member.rows.length) {
@ -64,8 +64,8 @@ export async function DELETE(
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
const adminUsername = req.headers.get("x-admin-username") || "unknown";
const member = await query<{ id: string; scope: string; user: string }>(
`SELECT id, scope, user FROM project_members WHERE id = $1 AND project = $2`,
const member = await query<{ id: string; scope: string; user_uuid: string }>(
`SELECT id, scope, user_uuid FROM project_members WHERE id = $1 AND project_uuid = $2`,
[memberId, id]
);
if (!member.rows.length) {
@ -83,7 +83,7 @@ export async function DELETE(
action: "delete",
resource: "project_member",
resourceId: memberId,
requestParams: { projectId: id, userId: member.rows[0].user },
requestParams: { projectId: id, userId: member.rows[0].user_uuid },
ipAddress: req.headers.get("x-forwarded-for") || undefined,
userAgent: req.headers.get("user-agent") || undefined,
});

View File

@ -13,21 +13,21 @@ export async function GET(
const { id } = await params;
const result = await query(
`SELECT pm.id, pm.project, pm.user, pm.scope, pm.joined_at::text,
`SELECT pm.id, pm.project_uuid, pm.user_uuid, pm.scope, pm.joined_at::text,
u.username, u.display_name, u.avatar_url,
COALESCE(up.is_active, true) as user_is_active
FROM project_members pm
JOIN "user" u ON u.uid = pm.user
JOIN "user" u ON u.uid = pm.user_uuid
LEFT JOIN user_password up ON up.user = u.uid
WHERE pm.project = $1
WHERE pm.project_uuid = $1
ORDER BY pm.scope = 'owner' DESC, pm.scope = 'admin' DESC, pm.joined_at ASC`,
[id]
);
const members = result.rows.map((r: Record<string, unknown>) => ({
id: r.id,
projectId: r.project,
userId: r.user,
projectId: r.project_uuid,
userId: r.user_uuid,
scope: r.scope,
joinedAt: r.joined_at,
username: r.username,
@ -81,7 +81,7 @@ export async function POST(
// 检查是否已是成员
const exist = await query(
`SELECT id FROM project_members WHERE project = $1 AND user = $2`,
`SELECT id FROM project_members WHERE project_uuid = $1 AND user_uuid = $2`,
[id, body.userId]
);
if (exist.rows.length) {
@ -89,7 +89,7 @@ export async function POST(
}
const result = await query(
`INSERT INTO project_members (project, user, scope, joined_at)
`INSERT INTO project_members (project_uuid, user_uuid, scope, joined_at)
VALUES ($1, $2, $3, NOW())
RETURNING id`,
[id, body.userId, scope]

View File

@ -8,41 +8,38 @@ export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const type = searchParams.get("type") || "all";
const providers = query(
`SELECT id, name, display_name, website, status, created_at
FROM ai_model_provider
ORDER BY name`
);
const models = query(
`SELECT m.id, m.name, m.modality, m.capability, m.context_length,
m.max_output_tokens, m.training_cutoff, m.is_open_source, m.status,
mv.model_id, mv.version,
p.id as provider_id, p.name as provider_name
FROM ai_model m
JOIN ai_model_provider p ON p.id = m.provider_id
LEFT JOIN ai_model_version mv ON mv.model_id = m.id AND mv.is_default = true
ORDER BY p.name, m.name`
);
const pricing = query(
`SELECT mp.id, mp.model_version_id, mp.input_price_per_1k_tokens, mp.output_price_per_1k_tokens,
mp.currency, mp.effective_from,
m.name as model_name, mv.model_id
FROM ai_model_pricing mp
JOIN ai_model_version mv ON mv.id = mp.model_version_id
JOIN ai_model m ON m.id = mv.model_id
ORDER BY mp.effective_from DESC
LIMIT 200`
);
const versions = query(
`SELECT mv.id, mv.model_id, mv.version, mv.release_date, mv.change_log, mv.is_default, mv.status, mv.created_at
FROM ai_model_version mv
ORDER BY mv.model_id, mv.version`
);
const [providersData, modelsData, pricingData, versionsData] = await Promise.all([providers, models, pricing, versions]);
const [providersData, modelsData, pricingData, versionsData] = await Promise.all([
query(
`SELECT id, name, display_name, website, status, created_at, updated_at
FROM ai_model_provider
ORDER BY name`
),
query(
`SELECT m.id, m.name, m.modality, m.capability, m.context_length,
m.max_output_tokens, m.training_cutoff, m.is_open_source, m.status,
mv.model_id, mv.version,
p.id as provider_id, p.name as provider_name
FROM ai_model m
JOIN ai_model_provider p ON p.id = m.provider_id
LEFT JOIN ai_model_version mv ON mv.model_id = m.id AND mv.is_default = true
ORDER BY p.name, m.name`
),
query(
`SELECT mp.id, mp.model_version_id, mp.input_price_per_1k_tokens, mp.output_price_per_1k_tokens,
mp.currency, mp.effective_from,
m.name as model_name, mv.model_id
FROM ai_model_pricing mp
JOIN ai_model_version mv ON mv.id = mp.model_version_id
JOIN ai_model m ON m.id = mv.model_id
ORDER BY mp.effective_from DESC
LIMIT 200`
),
query(
`SELECT mv.id, mv.model_id, mv.version, mv.release_date, mv.change_log, mv.is_default, mv.status, mv.created_at
FROM ai_model_version mv
ORDER BY mv.model_id, mv.version`
),
]);
const providersList = providersData.rows.map((r: Record<string, unknown>) => ({
id: String(r.id),

View File

@ -1,48 +1,18 @@
import { NextResponse } from "next/server";
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
import { syncModels } from "@/lib/adminrpc/client";
export const runtime = "nodejs";
/**
* Trigger AI model sync via Rust backend.
* Calls POST /api/admin/ai/sync on the Rust app.
* Trigger AI model sync via adminrpc gRPC.
*/
export async function POST() {
if (!ADMIN_API_SHARED_KEY) {
return NextResponse.json(
{ error: "ADMIN_API_SHARED_KEY 未配置" },
{ status: 500 }
);
}
try {
const url = `${RUST_BACKEND_URL}/api/admin/ai/sync`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-admin-api-key": ADMIN_API_SHARED_KEY,
},
// Timeout: 2 minutes for sync
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
const body = await res.text();
return NextResponse.json(
{ error: `同步失败: ${res.status} ${body}` },
{ status: res.status }
);
}
const data = await res.json();
const data = await syncModels();
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("AI sync error:", msg);
return NextResponse.json(
{ error: `同步失败: ${msg}` },
{ status: 500 }
);
return NextResponse.json({ error: `同步失败: ${msg}` }, { status: 500 });
}
}

View File

@ -1,47 +1,18 @@
import { NextResponse } from "next/server";
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
import { checkAlerts } from "@/lib/adminrpc/client";
export const runtime = "nodejs";
/**
* Trigger workspace billing alert check via Rust backend.
* Calls POST /api/admin/alerts/check on the Rust app.
* Trigger workspace billing alert check via adminrpc gRPC.
*/
export async function POST() {
if (!ADMIN_API_SHARED_KEY) {
return NextResponse.json(
{ error: "ADMIN_API_SHARED_KEY 未配置" },
{ status: 500 }
);
}
try {
const url = `${RUST_BACKEND_URL}/api/admin/alerts/check`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-admin-api-key": ADMIN_API_SHARED_KEY,
},
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
const body = await res.text();
return NextResponse.json(
{ error: `检查失败: ${res.status} ${body}` },
{ status: res.status }
);
}
const data = await res.json();
const data = await checkAlerts();
return NextResponse.json(data);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Alert check error:", msg);
return NextResponse.json(
{ error: `检查失败: ${msg}` },
{ status: 500 }
);
return NextResponse.json({ error: `检查失败: ${msg}` }, { status: 500 });
}
}

View File

@ -23,71 +23,65 @@ export async function GET(req: NextRequest) {
const action = searchParams.get("action") || "";
const offset = (page - 1) * pageSize;
// Build queries with proper parameter indexing
const actionPattern = action ? `%${action}%` : null;
const limitOffsetParams: unknown[] = [pageSize, offset];
let userQuery = "";
let projectQuery = "";
let queryParams: unknown[] = [];
let userCountQuery = "";
let projectCountQuery = "";
let paramIdx = 1;
let userParams: unknown[] = [];
let projectParams: unknown[] = [];
// Build user_activity_log query
if (source !== "project") {
if (action) {
userParams = [actionPattern, ...limitOffsetParams];
userQuery = `SELECT 'user_activity' as source, id,
COALESCE(user_uid::text, '') as actor_uid,
action, NULL::text as resource,
ip_address, user_agent, created_at::text as created_at
FROM user_activity_log
WHERE action ILIKE $${paramIdx}
WHERE action ILIKE $1
ORDER BY created_at DESC
LIMIT $${paramIdx + 1} OFFSET $${paramIdx + 2}`;
userCountQuery = `SELECT COUNT(*) FROM user_activity_log WHERE action ILIKE $${paramIdx}`;
queryParams.push(`%${action}%`, pageSize, offset);
paramIdx += 3;
LIMIT $2 OFFSET $3`;
} else {
userParams = limitOffsetParams;
userQuery = `SELECT 'user_activity' as source, id,
COALESCE(user_uid::text, '') as actor_uid,
action, NULL::text as resource,
ip_address, user_agent, created_at::text as created_at
FROM user_activity_log
ORDER BY created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
userCountQuery = `SELECT COUNT(*) FROM user_activity_log`;
queryParams.push(pageSize, offset);
paramIdx += 2;
LIMIT $1 OFFSET $2`;
}
}
// Build project_audit_log query
if (source !== "user") {
if (action) {
projectParams = [actionPattern, ...limitOffsetParams];
projectQuery = `SELECT 'project_audit' as source, id,
COALESCE(actor::text, '') as actor_uid,
action, details as resource, ip_address,
NULL as user_agent, created_at::text as created_at
FROM project_audit_log
WHERE action ILIKE $${paramIdx}
WHERE action ILIKE $1
ORDER BY created_at DESC
LIMIT $${paramIdx + 1} OFFSET $${paramIdx + 2}`;
projectCountQuery = `SELECT COUNT(*) FROM project_audit_log WHERE action ILIKE $${paramIdx}`;
queryParams.push(`%${action}%`, pageSize, offset);
paramIdx += 3;
LIMIT $2 OFFSET $3`;
} else {
projectParams = limitOffsetParams;
projectQuery = `SELECT 'project_audit' as source, id,
COALESCE(actor::text, '') as actor_uid,
action, details as resource, ip_address,
NULL as user_agent, created_at::text as created_at
FROM project_audit_log
ORDER BY created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
projectCountQuery = `SELECT COUNT(*) FROM project_audit_log`;
queryParams.push(pageSize, offset);
paramIdx += 2;
LIMIT $1 OFFSET $2`;
}
}
const [userLogs, projectLogs] = await Promise.all([
userQuery ? query<AuditLog>(userQuery, queryParams) : Promise.resolve({ rows: [] as AuditLog[] }),
projectQuery ? query<AuditLog>(projectQuery, queryParams) : Promise.resolve({ rows: [] as AuditLog[] }),
userQuery ? query<AuditLog>(userQuery, userParams) : Promise.resolve({ rows: [] as AuditLog[] }),
projectQuery ? query<AuditLog>(projectQuery, projectParams) : Promise.resolve({ rows: [] as AuditLog[] }),
]);
// 合并并排序
@ -97,8 +91,8 @@ export async function GET(req: NextRequest) {
// 总数
const [userCountRes, projectCountRes] = await Promise.all([
userCountQuery ? query<{ count: string }>(userCountQuery, action ? [`%${action}%`] : []) : Promise.resolve({ rows: [{ count: "0" }] }),
projectCountQuery ? query<{ count: string }>(projectCountQuery, action ? [`%${action}%`] : []) : Promise.resolve({ rows: [{ count: "0" }] }),
userCountQuery(userParams, action),
projectCountQuery(projectParams, action),
]);
const total = parseInt(String(userCountRes.rows[0]?.count || "0"), 10) +
parseInt(String(projectCountRes.rows[0]?.count || "0"), 10);
@ -109,3 +103,25 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}
async function userCountQuery(params: unknown[], action: string | null) {
if (!params.length) return { rows: [] as { count: string }[] };
if (action) {
return query<{ count: string }>(
`SELECT COUNT(*) FROM user_activity_log WHERE action ILIKE $1`,
[params[0]]
);
}
return query<{ count: string }>(`SELECT COUNT(*) FROM user_activity_log`);
}
async function projectCountQuery(params: unknown[], action: string | null) {
if (!params.length) return { rows: [] as { count: string }[] };
if (action) {
return query<{ count: string }>(
`SELECT COUNT(*) FROM project_audit_log WHERE action ILIKE $1`,
[params[0]]
);
}
return query<{ count: string }>(`SELECT COUNT(*) FROM project_audit_log`);
}

View File

@ -79,20 +79,20 @@ export async function PATCH(req: NextRequest) {
// Get user ids from uids
const uidPlaceholders = ids.map((_, i) => `$${i + 1}`).join(", ");
const uidResult = await query<{ id: number }>(
`SELECT id FROM "user" WHERE uid IN (${uidPlaceholders})`,
const uidResult = await query<{ uid: string }>(
`SELECT uid FROM "user" WHERE uid IN (${uidPlaceholders})`,
ids
);
const userIds = uidResult.rows.map((r) => r.id);
const uids = uidResult.rows.map((r) => r.uid);
if (!userIds.length) {
if (!uids.length) {
return NextResponse.json({ error: "未找到匹配的用户" }, { status: 404 });
}
const idPlaceholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
const uidPlaceholders2 = uids.map((_, i) => `$${i + 1}`).join(", ");
await query(
`UPDATE user_password SET is_active = $${userIds.length + 1}, updated_at = NOW() WHERE user_id IN (${idPlaceholders})`,
[...userIds, isActive]
`UPDATE user_password SET is_active = $${uids.length + 1}, updated_at = NOW() WHERE "user" IN (${uidPlaceholders2})`,
[...uids, isActive]
);
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
@ -102,13 +102,13 @@ export async function PATCH(req: NextRequest) {
username: adminUsername,
action: "update",
resource: "user_batch_status",
resourceId: `batch(${userIds.length})`,
requestParams: { uidCount: ids.length, userIdCount: userIds.length, action },
resourceId: `batch(${uids.length})`,
requestParams: { uidCount: ids.length, userIdCount: uids.length, action },
ipAddress: req.headers.get("x-forwarded-for") || undefined,
userAgent: req.headers.get("user-agent") || undefined,
});
return NextResponse.json({ success: true, updated: userIds.length });
return NextResponse.json({ success: true, updated: uids.length });
} catch (e) {
console.error("Batch update user status error:", e);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });

View File

@ -66,7 +66,7 @@ export async function POST(
// Insert billing history
await client.query(
`INSERT INTO workspace_billing_history
(workspace_id, user, amount, reason, extra, currency)
(workspace_id, user_id, amount, reason, extra, currency)
VALUES ($1, NULL, $2, 'admin_credit', $3, $4)`,
[id, amount, JSON.stringify({ description: description || "Admin 手动充值" }), currency]
);

View File

@ -0,0 +1,270 @@
/**
* AdminRPC gRPC client for Node.js environment.
*
* Uses raw HTTP/1.1 with the tonic gRPC server.
* All methods pass body_json as a JSON string (matching the proto design).
*/
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
import type { IncomingMessage } from "node:http";
import { ADMIN_RPC_URL } from "@/lib/env";
// Import schemas from generated proto
import * as admin from "./generated/proto/admin_pb";
// ─── Low-level gRPC-Web over HTTP/1.1 ────────────────────────────────────────
function grpcRequest(
baseUrl: string,
servicePath: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
responseSchema: any,
body: Uint8Array,
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const url = new URL(baseUrl);
const isHttps = url.protocol === "https:";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = (isHttps ? require("node:https") : require("node:http")) as typeof import("node:http");
const req = http.request(
{
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: servicePath,
method: "POST",
headers: {
"Content-Type": "application/grpc-web+proto",
"X-Grpc-Web": "1",
"TE": "trailers",
"User-Agent": "admin-module/1.0",
"Content-Length": body.byteLength,
},
},
(res: IncomingMessage) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
const data = Buffer.concat(chunks);
if (data.length === 0) {
const grpcStatus = res.headers["grpc-status"];
if (grpcStatus && grpcStatus !== "0") {
reject(new Error(`gRPC error ${grpcStatus}: ${res.headers["grpc-message"] || ""}`));
return;
}
resolve(new Uint8Array(0));
return;
}
// gRPC-web body format: [0x00, 4-byte big-endian length, payload...]
if (data[0] === 0x00 && data.length > 5) {
const bodyBytes = data.slice(5);
resolve(new Uint8Array(bodyBytes.buffer, bodyBytes.byteOffset, bodyBytes.byteLength));
} else {
resolve(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
}
});
},
);
req.on("error", reject);
req.write(body);
req.end();
});
}
// ─── Message encoding / decoding ──────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function encode(schema: any, init: Record<string, unknown>): Uint8Array {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msg = create(schema as any, init as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return toBinary(schema as any, msg as any);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function decode(schema: any, bytes: Uint8Array): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return fromBinary(schema as any, bytes) as any;
}
// ─── AI: Sync Models ──────────────────────────────────────────────────────────
export async function syncModels(): Promise<unknown> {
const body = encode(admin.SyncModelsRequestSchema, {});
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/SyncModels",
admin.SyncModelsRequestSchema,
admin.SyncModelsResponseSchema,
body,
);
if (bytes.length === 0) return {};
const resp = decode(admin.SyncModelsResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
// ─── AI: Check Alerts ─────────────────────────────────────────────────────────
export async function checkAlerts(): Promise<unknown> {
const body = encode(admin.CheckAlertsRequestSchema, {});
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/CheckAlerts",
admin.CheckAlertsRequestSchema,
admin.CheckAlertsResponseSchema,
body,
);
if (bytes.length === 0) return {};
const resp = decode(admin.CheckAlertsResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
// ─── AI: Provider CRUD ────────────────────────────────────────────────────────
export async function createProvider(body: unknown): Promise<unknown> {
const msgBody = encode(admin.CreateProviderRequestSchema, { bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/CreateProvider",
admin.CreateProviderRequestSchema,
admin.ProviderResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.ProviderResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
export async function updateProvider(id: string, body: unknown): Promise<unknown> {
const msgBody = encode(admin.UpdateProviderRequestSchema, { id, bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/UpdateProvider",
admin.UpdateProviderRequestSchema,
admin.ProviderResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.ProviderResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
export async function deleteProvider(id: string): Promise<{ deleted: boolean }> {
const msgBody = encode(admin.DeleteProviderRequestSchema, { id });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/DeleteProvider",
admin.DeleteProviderRequestSchema,
admin.DeleteResponseSchema,
msgBody,
);
if (bytes.length === 0) return { deleted: false };
const resp = decode(admin.DeleteResponseSchema, bytes);
return { deleted: resp.deleted };
}
// ─── AI: Model CRUD ───────────────────────────────────────────────────────────
export async function createModel(body: unknown): Promise<unknown> {
const msgBody = encode(admin.CreateModelRequestSchema, { bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/CreateModel",
admin.CreateModelRequestSchema,
admin.ModelResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.ModelResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
export async function updateModel(id: string, body: unknown): Promise<unknown> {
const msgBody = encode(admin.UpdateModelRequestSchema, { id, bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/UpdateModel",
admin.UpdateModelRequestSchema,
admin.ModelResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.ModelResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
export async function deleteModel(id: string): Promise<{ deleted: boolean }> {
const msgBody = encode(admin.DeleteModelRequestSchema, { id });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/DeleteModel",
admin.DeleteModelRequestSchema,
admin.DeleteResponseSchema,
msgBody,
);
if (bytes.length === 0) return { deleted: false };
const resp = decode(admin.DeleteResponseSchema, bytes);
return { deleted: resp.deleted };
}
// ─── AI: Version CRUD ─────────────────────────────────────────────────────────
export async function createVersion(body: unknown): Promise<unknown> {
const msgBody = encode(admin.CreateVersionRequestSchema, { bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/CreateVersion",
admin.CreateVersionRequestSchema,
admin.VersionResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.VersionResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
export async function updateVersion(id: string, body: unknown): Promise<unknown> {
const msgBody = encode(admin.UpdateVersionRequestSchema, { id, bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/UpdateVersion",
admin.UpdateVersionRequestSchema,
admin.VersionResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.VersionResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}
export async function deleteVersion(id: string): Promise<{ deleted: boolean }> {
const msgBody = encode(admin.DeleteVersionRequestSchema, { id });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/DeleteVersion",
admin.DeleteVersionRequestSchema,
admin.DeleteResponseSchema,
msgBody,
);
if (bytes.length === 0) return { deleted: false };
const resp = decode(admin.DeleteResponseSchema, bytes);
return { deleted: resp.deleted };
}
// ─── AI: Pricing Update ───────────────────────────────────────────────────────
export async function updatePricing(id: string, body: unknown): Promise<unknown> {
const msgBody = encode(admin.UpdatePricingRequestSchema, { id, bodyJson: JSON.stringify(body) });
const bytes = await grpcRequest(
ADMIN_RPC_URL,
"/admin.SessionAdmin/UpdatePricing",
admin.UpdatePricingRequestSchema,
admin.PricingResponseSchema,
msgBody,
);
if (bytes.length === 0) return {};
const resp = decode(admin.PricingResponseSchema, bytes);
return JSON.parse(resp.bodyJson || "{}");
}

View File

@ -0,0 +1,225 @@
// @generated by protoc-gen-connect-es v0.13.0
// @generated from file proto/admin.proto (package admin, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { CheckAlertsRequest, CheckAlertsResponse, CreateModelRequest, CreateProviderRequest, CreateVersionRequest, DeleteModelRequest, DeleteProviderRequest, DeleteResponse, DeleteVersionRequest, ExportMetricsCsvRequest, ExportMetricsCsvResponse, GetMetricsRequest, GetMetricsResponse, GetUserInfoRequest, GetUserInfoResponse, GetUserStatusRequest, GetUserStatusResponse, GetWorkspaceOnlineUsersRequest, GetWorkspaceOnlineUsersResponse, IsUserOnlineRequest, IsUserOnlineResponse, KickUserFromWorkspaceRequest, KickUserFromWorkspaceResponse, KickUserRequest, KickUserResponse, ListUserSessionsRequest, ListUserSessionsResponse, ListWorkspaceSessionsRequest, ListWorkspaceSessionsResponse, ModelResponse, PricingResponse, ProviderResponse, SyncModelsRequest, SyncModelsResponse, UpdateModelRequest, UpdatePricingRequest, UpdateProviderRequest, UpdateVersionRequest, VersionResponse } from "./admin_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
* @generated from service admin.SessionAdmin
*/
export declare const SessionAdmin: {
readonly typeName: "admin.SessionAdmin",
readonly methods: {
/**
* @generated from rpc admin.SessionAdmin.ListWorkspaceSessions
*/
readonly listWorkspaceSessions: {
readonly name: "ListWorkspaceSessions",
readonly I: typeof ListWorkspaceSessionsRequest,
readonly O: typeof ListWorkspaceSessionsResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.ListUserSessions
*/
readonly listUserSessions: {
readonly name: "ListUserSessions",
readonly I: typeof ListUserSessionsRequest,
readonly O: typeof ListUserSessionsResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.KickUserFromWorkspace
*/
readonly kickUserFromWorkspace: {
readonly name: "KickUserFromWorkspace",
readonly I: typeof KickUserFromWorkspaceRequest,
readonly O: typeof KickUserFromWorkspaceResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.KickUser
*/
readonly kickUser: {
readonly name: "KickUser",
readonly I: typeof KickUserRequest,
readonly O: typeof KickUserResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetUserStatus
*/
readonly getUserStatus: {
readonly name: "GetUserStatus",
readonly I: typeof GetUserStatusRequest,
readonly O: typeof GetUserStatusResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetUserInfo
*/
readonly getUserInfo: {
readonly name: "GetUserInfo",
readonly I: typeof GetUserInfoRequest,
readonly O: typeof GetUserInfoResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetWorkspaceOnlineUsers
*/
readonly getWorkspaceOnlineUsers: {
readonly name: "GetWorkspaceOnlineUsers",
readonly I: typeof GetWorkspaceOnlineUsersRequest,
readonly O: typeof GetWorkspaceOnlineUsersResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.IsUserOnline
*/
readonly isUserOnline: {
readonly name: "IsUserOnline",
readonly I: typeof IsUserOnlineRequest,
readonly O: typeof IsUserOnlineResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetMetrics
*/
readonly getMetrics: {
readonly name: "GetMetrics",
readonly I: typeof GetMetricsRequest,
readonly O: typeof GetMetricsResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.ExportMetricsCsv
*/
readonly exportMetricsCsv: {
readonly name: "ExportMetricsCsv",
readonly I: typeof ExportMetricsCsvRequest,
readonly O: typeof ExportMetricsCsvResponse,
readonly kind: MethodKind.Unary,
},
/**
* AI
*
* @generated from rpc admin.SessionAdmin.SyncModels
*/
readonly syncModels: {
readonly name: "SyncModels",
readonly I: typeof SyncModelsRequest,
readonly O: typeof SyncModelsResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.CheckAlerts
*/
readonly checkAlerts: {
readonly name: "CheckAlerts",
readonly I: typeof CheckAlertsRequest,
readonly O: typeof CheckAlertsResponse,
readonly kind: MethodKind.Unary,
},
/**
* AI Provider
*
* @generated from rpc admin.SessionAdmin.CreateProvider
*/
readonly createProvider: {
readonly name: "CreateProvider",
readonly I: typeof CreateProviderRequest,
readonly O: typeof ProviderResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.UpdateProvider
*/
readonly updateProvider: {
readonly name: "UpdateProvider",
readonly I: typeof UpdateProviderRequest,
readonly O: typeof ProviderResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.DeleteProvider
*/
readonly deleteProvider: {
readonly name: "DeleteProvider",
readonly I: typeof DeleteProviderRequest,
readonly O: typeof DeleteResponse,
readonly kind: MethodKind.Unary,
},
/**
* AI Model
*
* @generated from rpc admin.SessionAdmin.CreateModel
*/
readonly createModel: {
readonly name: "CreateModel",
readonly I: typeof CreateModelRequest,
readonly O: typeof ModelResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.UpdateModel
*/
readonly updateModel: {
readonly name: "UpdateModel",
readonly I: typeof UpdateModelRequest,
readonly O: typeof ModelResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.DeleteModel
*/
readonly deleteModel: {
readonly name: "DeleteModel",
readonly I: typeof DeleteModelRequest,
readonly O: typeof DeleteResponse,
readonly kind: MethodKind.Unary,
},
/**
* AI Version
*
* @generated from rpc admin.SessionAdmin.CreateVersion
*/
readonly createVersion: {
readonly name: "CreateVersion",
readonly I: typeof CreateVersionRequest,
readonly O: typeof VersionResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.UpdateVersion
*/
readonly updateVersion: {
readonly name: "UpdateVersion",
readonly I: typeof UpdateVersionRequest,
readonly O: typeof VersionResponse,
readonly kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.DeleteVersion
*/
readonly deleteVersion: {
readonly name: "DeleteVersion",
readonly I: typeof DeleteVersionRequest,
readonly O: typeof DeleteResponse,
readonly kind: MethodKind.Unary,
},
/**
* AI Pricing
*
* @generated from rpc admin.SessionAdmin.UpdatePricing
*/
readonly updatePricing: {
readonly name: "UpdatePricing",
readonly I: typeof UpdatePricingRequest,
readonly O: typeof PricingResponse,
readonly kind: MethodKind.Unary,
},
}
};

View File

@ -0,0 +1,225 @@
// @generated by protoc-gen-connect-es v0.13.0
// @generated from file proto/admin.proto (package admin, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { CheckAlertsRequest, CheckAlertsResponse, CreateModelRequest, CreateProviderRequest, CreateVersionRequest, DeleteModelRequest, DeleteProviderRequest, DeleteResponse, DeleteVersionRequest, ExportMetricsCsvRequest, ExportMetricsCsvResponse, GetMetricsRequest, GetMetricsResponse, GetUserInfoRequest, GetUserInfoResponse, GetUserStatusRequest, GetUserStatusResponse, GetWorkspaceOnlineUsersRequest, GetWorkspaceOnlineUsersResponse, IsUserOnlineRequest, IsUserOnlineResponse, KickUserFromWorkspaceRequest, KickUserFromWorkspaceResponse, KickUserRequest, KickUserResponse, ListUserSessionsRequest, ListUserSessionsResponse, ListWorkspaceSessionsRequest, ListWorkspaceSessionsResponse, ModelResponse, PricingResponse, ProviderResponse, SyncModelsRequest, SyncModelsResponse, UpdateModelRequest, UpdatePricingRequest, UpdateProviderRequest, UpdateVersionRequest, VersionResponse } from "./admin_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
* @generated from service admin.SessionAdmin
*/
export const SessionAdmin = {
typeName: "admin.SessionAdmin",
methods: {
/**
* @generated from rpc admin.SessionAdmin.ListWorkspaceSessions
*/
listWorkspaceSessions: {
name: "ListWorkspaceSessions",
I: ListWorkspaceSessionsRequest,
O: ListWorkspaceSessionsResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.ListUserSessions
*/
listUserSessions: {
name: "ListUserSessions",
I: ListUserSessionsRequest,
O: ListUserSessionsResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.KickUserFromWorkspace
*/
kickUserFromWorkspace: {
name: "KickUserFromWorkspace",
I: KickUserFromWorkspaceRequest,
O: KickUserFromWorkspaceResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.KickUser
*/
kickUser: {
name: "KickUser",
I: KickUserRequest,
O: KickUserResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetUserStatus
*/
getUserStatus: {
name: "GetUserStatus",
I: GetUserStatusRequest,
O: GetUserStatusResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetUserInfo
*/
getUserInfo: {
name: "GetUserInfo",
I: GetUserInfoRequest,
O: GetUserInfoResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetWorkspaceOnlineUsers
*/
getWorkspaceOnlineUsers: {
name: "GetWorkspaceOnlineUsers",
I: GetWorkspaceOnlineUsersRequest,
O: GetWorkspaceOnlineUsersResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.IsUserOnline
*/
isUserOnline: {
name: "IsUserOnline",
I: IsUserOnlineRequest,
O: IsUserOnlineResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.GetMetrics
*/
getMetrics: {
name: "GetMetrics",
I: GetMetricsRequest,
O: GetMetricsResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.ExportMetricsCsv
*/
exportMetricsCsv: {
name: "ExportMetricsCsv",
I: ExportMetricsCsvRequest,
O: ExportMetricsCsvResponse,
kind: MethodKind.Unary,
},
/**
* AI
*
* @generated from rpc admin.SessionAdmin.SyncModels
*/
syncModels: {
name: "SyncModels",
I: SyncModelsRequest,
O: SyncModelsResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.CheckAlerts
*/
checkAlerts: {
name: "CheckAlerts",
I: CheckAlertsRequest,
O: CheckAlertsResponse,
kind: MethodKind.Unary,
},
/**
* AI Provider
*
* @generated from rpc admin.SessionAdmin.CreateProvider
*/
createProvider: {
name: "CreateProvider",
I: CreateProviderRequest,
O: ProviderResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.UpdateProvider
*/
updateProvider: {
name: "UpdateProvider",
I: UpdateProviderRequest,
O: ProviderResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.DeleteProvider
*/
deleteProvider: {
name: "DeleteProvider",
I: DeleteProviderRequest,
O: DeleteResponse,
kind: MethodKind.Unary,
},
/**
* AI Model
*
* @generated from rpc admin.SessionAdmin.CreateModel
*/
createModel: {
name: "CreateModel",
I: CreateModelRequest,
O: ModelResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.UpdateModel
*/
updateModel: {
name: "UpdateModel",
I: UpdateModelRequest,
O: ModelResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.DeleteModel
*/
deleteModel: {
name: "DeleteModel",
I: DeleteModelRequest,
O: DeleteResponse,
kind: MethodKind.Unary,
},
/**
* AI Version
*
* @generated from rpc admin.SessionAdmin.CreateVersion
*/
createVersion: {
name: "CreateVersion",
I: CreateVersionRequest,
O: VersionResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.UpdateVersion
*/
updateVersion: {
name: "UpdateVersion",
I: UpdateVersionRequest,
O: VersionResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc admin.SessionAdmin.DeleteVersion
*/
deleteVersion: {
name: "DeleteVersion",
I: DeleteVersionRequest,
O: DeleteResponse,
kind: MethodKind.Unary,
},
/**
* AI Pricing
*
* @generated from rpc admin.SessionAdmin.UpdatePricing
*/
updatePricing: {
name: "UpdatePricing",
I: UpdatePricingRequest,
O: PricingResponse,
kind: MethodKind.Unary,
},
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -167,7 +167,8 @@ export async function touchSession(sessionId: string): Promise<void> {
const state = await loadSession(sessionId);
if (!state) return;
state["session:last_active"] = new Date().toISOString();
await refreshSessionTtl(sessionId, ADMIN_SESSION_TTL);
const { saveSession } = await import("@/lib/redis");
await saveSession(sessionId, state, ADMIN_SESSION_TTL);
}
// ============ 登出 ============

View File

@ -30,12 +30,16 @@ async function runReport() {
// Call generate endpoint internally (server-side fetch, no auth needed for cron)
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-cron-internal": "true",
};
if (process.env.DAILY_REPORT_CRON_SECRET) {
headers["x-cron-secret"] = process.env.DAILY_REPORT_CRON_SECRET;
}
const res = await fetch(`${baseUrl}/api/admin/daily-report/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-cron-internal": "true", // internal marker, not x-cron-secret
},
headers,
});
const data = await res.json().catch(() => ({}));
console.log("[daily-report-cron] Result:", res.status, data);

View File

@ -5,29 +5,29 @@
// 数据库
export const DATABASE_URL =
process.env.DATABASE_URL || "postgresql://localhost:5432/code";
process.env.DATABASE_URL || "postgresql://localhost:5432/code";
// Redis
export const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
// Redis Cluster 节点列表(逗号分隔,用于 ioredis cluster 模式)
export const REDIS_CLUSTER_URLS = (process.env.REDIS_CLUSTER_URLS || "")
.split(",")
.map((u) => u.trim())
.filter(Boolean);
.split(",")
.map((u) => u.trim())
.filter(Boolean);
// Session
export const ADMIN_SESSION_COOKIE_NAME =
process.env.ADMIN_SESSION_COOKIE_NAME || "admin_session";
process.env.ADMIN_SESSION_COOKIE_NAME || "admin_session";
export const ADMIN_SESSION_TTL = parseInt(
process.env.ADMIN_SESSION_TTL || "604800",
10
process.env.ADMIN_SESSION_TTL || "604800",
10
); // 7 days
// 超级管理员(环境变量配置)
export const ADMIN_SUPER_USERNAME = process.env.ADMIN_SUPER_USERNAME || "";
export const ADMIN_SUPER_PASSWORD = process.env.ADMIN_SUPER_PASSWORD || "";
export const ADMIN_SUPER_PASSWORD_HASH =
process.env.ADMIN_SUPER_PASSWORD_HASH || "";
process.env.ADMIN_SUPER_PASSWORD_HASH || "";
// OIDC
export const OIDC_ENABLED = process.env.OIDC_ENABLED === "true";
@ -35,21 +35,21 @@ export const OIDC_ISSUER = process.env.OIDC_ISSUER || "";
export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || "";
export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || "";
export const OIDC_REDIRECT_URI =
process.env.OIDC_REDIRECT_URI ||
"http://localhost:3000/api/auth/oidc/callback";
process.env.OIDC_REDIRECT_URI ||
"http://localhost:3000/api/auth/oidc/callback";
// Cookie 安全
export const COOKIE_SECURE = process.env.COOKIE_SECURE === "true";
export const COOKIE_SAME_SITE =
(process.env.COOKIE_SAME_SITE as "strict" | "lax" | "none") || "lax";
(process.env.COOKIE_SAME_SITE as "strict" | "lax" | "none") || "lax";
// Rust 主应用集成
export const RUST_BACKEND_URL =
process.env.RUST_BACKEND_URL || "http://localhost:3000";
process.env.RUST_BACKEND_URL || "http://localhost:3000";
export const ADMIN_API_SHARED_KEY =
process.env.ADMIN_API_SHARED_KEY || "";
process.env.ADMIN_API_SHARED_KEY || "";
// adminrpc HTTP 服务地址k8s 内部默认地址)
// 在 Kubernetes 环境中默认使用 Service DNS在本地开发时覆盖为 localhost:9091
export const ADMIN_RPC_URL =
process.env.ADMIN_RPC_URL || "http://adminrpc.admin.svc.cluster.local:9091";
process.env.ADMIN_RPC_URL || "http://adminrpc.gitdataai.svc.cluster.local:9091";

View File

@ -201,15 +201,12 @@ export async function listUsers(
const pageSize = options.pageSize ?? 20;
const offset = (page - 1) * pageSize;
const params: unknown[] = [pageSize, offset];
let whereClause = "";
let paramIdx = 3;
if (options.search) {
whereClause = `WHERE username ILIKE $1`;
params.unshift(`%${options.search}%`);
paramIdx = 3;
}
const params: unknown[] = [
...(options.search ? [`%${options.search}%`] : []),
pageSize,
offset,
];
const whereClause = options.search ? `WHERE username ILIKE $1` : "";
const countParams = options.search ? [`%${options.search}%`] : [];
const countResult = await query<{ count: string }>(

View File

@ -10,7 +10,7 @@ import { REDIS_URL, REDIS_CLUSTER_URLS } from "./env";
// Admin 专用的 Redis 前缀
const ADMIN_PREFIX = "admin:session:";
// 平台用户 Session 前缀(与 Rust 主应用一致)
const PLATFORM_SESSION_PREFIX = "session:user_uid:";
const PLATFORM_SESSION_PREFIX = "user:";
let redis: Redis | null = null;

View File

@ -124,6 +124,7 @@ export async function middleware(req: NextRequest) {
permissions = tokenResult.permissions || [];
headers.set("x-admin-auth-type", "token");
headers.set("x-admin-token-id", String(tokenResult.tokenId));
headers.set("x-admin-permissions", permissions.join(","));
} else {
// 回退到 Session 认证
const cookieHeader = req.headers.get("cookie");

View File

@ -1,11 +1,11 @@
use std::net::SocketAddr;
use actix_web::{web, App as ActixApp, HttpResponse, HttpServer};
use anyhow::Context as _;
use clap::Parser;
use config::AppConfig;
use deadpool_redis::{cluster, Runtime};
use session_manager::{SessionManager, SessionStorage};
use rpc::admin::server::{serve, DEFAULT_GRPC_PORT};
use session_manager::{SessionManager, SessionStorage};
use std::net::SocketAddr;
use uuid::Uuid;
mod args;
@ -24,9 +24,8 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or_else(|| format!("0.0.0.0:{}", DEFAULT_GRPC_PORT).parse())
.context("invalid grpc bind address")?;
// Admin HTTP port is gRPC port + 1 (e.g., 9091)
let admin_port: u16 = args.http_port.unwrap_or(grpc_addr.port() + 1);
let admin_addr: SocketAddr = format!("0.0.0.0:{}", admin_port).parse().unwrap();
let admin_addr: SocketAddr = format!("0.0.0.0:{}", admin_port).parse()?;
tracing::info!(
app_name = %cfg.app_name().unwrap_or_default(),
@ -35,20 +34,25 @@ async fn main() -> anyhow::Result<()> {
"Starting admin RPC server"
);
// ── OTLP tracing ─────────────────────────────────────────────────────────
let _otel_guard = if cfg.otel_enabled().unwrap_or(false) {
let endpoint = cfg.otel_endpoint().unwrap_or_else(|_| "http://localhost:4317".to_string());
let service_name = cfg.otel_service_name().unwrap_or_else(|_| "adminrpc".to_string());
let service_version = cfg.otel_service_version().unwrap_or_else(|_| "0.1.0".to_string());
let endpoint = cfg
.otel_endpoint()
.unwrap_or_else(|_| "http://localhost:4317".to_string());
let service_name = cfg
.otel_service_name()
.unwrap_or_else(|_| "adminrpc".to_string());
let service_version = cfg
.otel_service_version()
.unwrap_or_else(|_| "0.1.0".to_string());
tracing::info!(endpoint = %endpoint, service = %service_name, "OTLP tracing enabled");
let guard = observability::init_otlp(&endpoint, &service_name, &service_version, &log_level)
.map_err(|e| anyhow::anyhow!("OTLP init failed: {}", e))?;
let guard =
observability::init_otlp(&endpoint, &service_name, &service_version, &log_level)
.map_err(|e| anyhow::anyhow!("OTLP init failed: {}", e))?;
guard
} else {
None
};
// Redis connection pool
let redis_url = cfg.redis_url()?;
tracing::info!(redis_url = %redis_url, "Connecting to Redis");
let manager = cluster::Manager::new(vec![redis_url.clone()], false)
@ -65,7 +69,6 @@ async fn main() -> anyhow::Result<()> {
let storage = SessionStorage::new(pool.clone());
let session_manager = SessionManager::new(storage);
// Spawn gRPC server in background
let sm_for_grpc = session_manager.clone();
let grpc_handle = tokio::spawn(async move {
if let Err(e) = serve(grpc_addr, sm_for_grpc).await {
@ -73,7 +76,6 @@ async fn main() -> anyhow::Result<()> {
}
});
// Start HTTP REST server
let http_handle = tokio::spawn(async move {
let pool_for_http = pool.clone();
let sm_for_http = session_manager.clone();
@ -86,15 +88,35 @@ async fn main() -> anyhow::Result<()> {
.route("/admin/metrics/export", web::get().to(metrics_export))
.service(
web::scope("/api/admin")
// Sessions
.route("/sessions/workspace/{workspace_id}", web::get().to(list_workspace_sessions))
.route("/sessions/user/{user_id}", web::get().to(list_user_sessions))
.route("/sessions/user/{user_id}/status", web::get().to(get_user_status))
.route("/sessions/user/{user_id}/info", web::get().to(get_user_info))
.route("/sessions/workspace/{workspace_id}/online-users", web::get().to(get_workspace_online_users))
.route("/sessions/user/{user_id}/online", web::get().to(is_user_online))
.route(
"/sessions/workspace/{workspace_id}",
web::get().to(list_workspace_sessions),
)
.route(
"/sessions/user/{user_id}",
web::get().to(list_user_sessions),
)
.route(
"/sessions/user/{user_id}/status",
web::get().to(get_user_status),
)
.route(
"/sessions/user/{user_id}/info",
web::get().to(get_user_info),
)
.route(
"/sessions/workspace/{workspace_id}/online-users",
web::get().to(get_workspace_online_users),
)
.route(
"/sessions/user/{user_id}/online",
web::get().to(is_user_online),
)
.route("/sessions/kick", web::post().to(kick_user))
.route("/sessions/kick-workspace", web::post().to(kick_user_from_workspace))
.route(
"/sessions/kick-workspace",
web::post().to(kick_user_from_workspace),
)
// Metrics
.route("/metrics", web::get().to(get_metrics))
.route("/metrics/export", web::get().to(metrics_export)),
@ -131,14 +153,18 @@ async fn metrics_export(pool: web::Data<cluster::Pool>) -> HttpResponse {
Ok(csv) => HttpResponse::Ok()
.content_type("text/csv; charset=utf-8")
.body(csv),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
async fn get_metrics(pool: web::Data<cluster::Pool>) -> HttpResponse {
match observability::query_all_instance_metrics(pool.get_ref(), "", 100).await {
Ok(instances) => HttpResponse::Ok().json(instances),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
@ -152,11 +178,16 @@ async fn list_workspace_sessions(
) -> HttpResponse {
let workspace_id = match parse_uuid(&path) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid workspace_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid workspace_id" }));
}
};
match sm.get_workspace_sessions(&workspace_id).await {
Ok(sessions) => HttpResponse::Ok().json(sessions),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
@ -166,39 +197,50 @@ async fn list_user_sessions(
) -> HttpResponse {
let user_id = match parse_uuid(&path) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid user_id" }));
}
};
match sm.get_user_sessions(&user_id).await {
Ok(sessions) => HttpResponse::Ok().json(sessions),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
async fn get_user_status(
sm: web::Data<SessionManager>,
path: web::Path<String>,
) -> HttpResponse {
async fn get_user_status(sm: web::Data<SessionManager>, path: web::Path<String>) -> HttpResponse {
let user_id = match parse_uuid(&path) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid user_id" }));
}
};
match sm.get_user_status(&user_id).await {
Ok(status) => HttpResponse::Ok().json(serde_json::json!({ "status": format!("{:?}", status) })),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Ok(status) => {
HttpResponse::Ok().json(serde_json::json!({ "status": format!("{:?}", status) }))
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
async fn get_user_info(
sm: web::Data<SessionManager>,
path: web::Path<String>,
) -> HttpResponse {
async fn get_user_info(sm: web::Data<SessionManager>, path: web::Path<String>) -> HttpResponse {
let user_id = match parse_uuid(&path) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid user_id" }));
}
};
match sm.get_user_info(&user_id).await {
Ok(info) => HttpResponse::Ok().json(info),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
@ -208,25 +250,32 @@ async fn get_workspace_online_users(
) -> HttpResponse {
let workspace_id = match parse_uuid(&path) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid workspace_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid workspace_id" }));
}
};
match sm.get_workspace_online_users(&workspace_id).await {
Ok(user_ids) => HttpResponse::Ok().json(serde_json::json!({ "user_ids": user_ids })),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
async fn is_user_online(
sm: web::Data<SessionManager>,
path: web::Path<String>,
) -> HttpResponse {
async fn is_user_online(sm: web::Data<SessionManager>, path: web::Path<String>) -> HttpResponse {
let user_id = match parse_uuid(&path) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid user_id" }));
}
};
match sm.is_user_online(&user_id).await {
Ok(online) => HttpResponse::Ok().json(serde_json::json!({ "online": online })),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
@ -241,11 +290,16 @@ async fn kick_user(
) -> HttpResponse {
let user_id = match parse_uuid(&body.user_id) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid user_id" }));
}
};
match sm.kick_user(&user_id).await {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({ "kicked_count": count })),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}
@ -261,14 +315,22 @@ async fn kick_user_from_workspace(
) -> HttpResponse {
let user_id = match parse_uuid(&body.user_id) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid user_id" }));
}
};
let workspace_id = match parse_uuid(&body.workspace_id) {
Some(id) => id,
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid workspace_id" })),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "invalid workspace_id" }));
}
};
match sm.kick_user_from_workspace(&user_id, &workspace_id).await {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({ "kicked_count": count })),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
}
}
}

View File

@ -1,4 +1,5 @@
use std::pin::Pin;
use std::time::Duration;
use async_openai::config::OpenAIConfig;
use async_openai::Client;
use async_openai::types::chat::{
@ -707,6 +708,28 @@ impl ChatService {
.await
}
/// Returns true if the error message indicates a transient failure that can be retried.
fn is_retryable_tool_error(msg: &str) -> bool {
let msg_lower = msg.to_lowercase();
// Transient errors: network, timeouts, rate limits, permission issues that may be temporary
msg_lower.contains("connection")
|| msg_lower.contains("timeout")
|| msg_lower.contains("timed out")
|| msg_lower.contains("rate limit")
|| msg_lower.contains("too many")
|| msg_lower.contains("unavailable")
|| msg_lower.contains("service unavailable")
|| msg_lower.contains("temporarily")
|| msg_lower.contains("refused")
|| msg_lower.contains("reset")
|| msg_lower.contains("broken pipe")
|| msg_lower.contains("deadline exceeded")
|| msg_lower.contains("try again")
|| msg_lower.contains("not found") // DB/Redis transient not-found
|| msg_lower.contains("permission denied")
|| msg_lower.contains("access denied")
}
/// Process a request using the ReAct (Reasoning + Acting) agent.
///
/// Unlike the simple loop in `process`, the ReAct agent performs multi-step
@ -756,27 +779,70 @@ impl ChatService {
let registry = registry.clone();
Box::pin(async move {
let mut ctx = ToolContext::new(db, cache, config, room_id, sender_uid);
if let Some(pid) = project_id {
ctx = ctx.with_project(pid);
}
ctx.registry_mut().merge(registry.clone());
let max_retries = 3;
let mut last_err = String::new();
let tool_executor = ToolExecutor::new();
let call = ToolCall {
id: uuid::Uuid::new_v4().to_string(),
name,
arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()),
};
let results: Vec<_> = tool_executor
.execute_batch(vec![call], &mut ctx)
.await
.map_err(|e| e.to_string())?;
let result = results.into_iter().next().ok_or_else(|| "no result".to_string())?;
match result.result {
ToolResult::Ok(v) => Ok(v),
ToolResult::Error(msg) => Err(msg),
for attempt in 0..=max_retries {
let mut ctx = ToolContext::new(db.clone(), cache.clone(), config.clone(), room_id, sender_uid);
if let Some(pid) = project_id {
ctx = ctx.with_project(pid);
}
ctx.registry_mut().merge(registry.clone());
let tool_executor = ToolExecutor::new();
let call = ToolCall {
id: Uuid::new_v4().to_string(),
name: name.clone(),
arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()),
};
match tool_executor.execute_batch(vec![call], &mut ctx).await {
Ok(results) => {
let result = results.into_iter().next()
.ok_or_else(|| "no tool result returned".to_string())?;
match result.result {
ToolResult::Ok(v) => return Ok(v),
ToolResult::Error(msg) => {
// Check if error is retryable
if attempt < max_retries && Self::is_retryable_tool_error(&msg) {
last_err = msg;
let backoff_ms = 100u64.saturating_mul(2u64.pow(attempt as u32));
tracing::warn!(
tool = %name,
attempt = attempt + 1,
backoff_ms = backoff_ms,
error = %last_err,
"tool_execute_retry"
);
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
continue;
}
// Non-retryable or exhausted retries — pass error to AI as observation
return Err(msg);
}
}
}
Err(e) => {
last_err = e.to_string();
if attempt < max_retries && Self::is_retryable_tool_error(&last_err) {
let backoff_ms = 100u64.saturating_mul(2u64.pow(attempt as u32));
tracing::warn!(
tool = %name,
attempt = attempt + 1,
backoff_ms = backoff_ms,
error = %last_err,
"tool_execute_retry"
);
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
continue;
}
return Err(last_err);
}
}
}
// Should not reach here, but just in case
Err(last_err)
}) as Pin<Box<dyn std::future::Future<Output = std::result::Result<serde_json::Value, String>> + Send>>
});

View File

@ -57,6 +57,18 @@ You must respond in JSON format:
- Chain multiple tool calls if a single call is insufficient.
- After each tool result, re-evaluate whether more data is needed before providing a final answer.
## Handling Tool Errors
When a tool returns an error observation (a JSON object with an "error" field):
- **Transient errors** (e.g., "connection refused", "not found", "timeout", "rate limit", "permission denied"): Retry with adjusted arguments, or try an alternative tool.
- **Permanent errors** (e.g., "invalid arguments", "tool not registered"): Do NOT retry acknowledge the error and try a different approach or reformulate your question.
- **Empty results** (e.g., "no issues found"): This is NOT an error continue with the next logical tool or provide your answer based on what was found.
The system automatically retries transient failures up to 3 times with backoff, but you should still:
1. Fix any malformed arguments before retrying.
2. If the same tool fails twice with the same error, switch to a different approach.
3. Always provide a useful answer even if all tools fail state what you attempted and what went wrong.
## Principles
- Be precise and cite specific issue/PR numbers, commit hashes, or message IDs when available.

View File

@ -432,6 +432,9 @@ use utoipa::OpenApi;
crate::user::subscribe::get_subscribers,
crate::user::subscribe::get_subscription_count,
crate::user::subscribe::get_subscriber_count,
crate::user::subscribe::get_following_list,
crate::user::user_activity::get_user_activity,
crate::user::stars::get_user_stars,
crate::user::user_info::get_user_info,
// Skill
crate::skill::skill_list,
@ -623,6 +626,12 @@ use utoipa::OpenApi;
service::user::repository::UserReposResponse,
service::user::repository::UserReposQuery,
service::user::subscribe::SubscriptionInfo,
service::user::subscribe::UserCard,
service::user::user_activity::UserActivityItem,
service::user::user_activity::UserActivityResponse,
service::user::stars::RepoStarItem,
service::user::stars::ProjectFollowItem,
service::user::stars::UserStarsResponse,
service::user::user_info::UserInfoExternal,
// Workspace
service::workspace::init::WorkspaceInitParams,

View File

@ -6,7 +6,9 @@ pub mod profile;
pub mod projects;
pub mod repository;
pub mod ssh_key;
pub mod stars;
pub mod subscribe;
pub mod user_activity;
pub mod user_info;
use actix_web::web;
@ -86,6 +88,8 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
web::get().to(chpc::get_contribution_heatmap),
)
.route("/{username}/keys", web::get().to(ssh_key::list_ssh_keys))
.route("/{username}/activity", web::get().to(user_activity::get_user_activity))
.route("/{username}/stars", web::get().to(stars::get_user_stars))
.route(
"/{username}/keys/{key_id}",
web::get().to(ssh_key::get_ssh_key),
@ -118,6 +122,10 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
"/{username}/following/count",
web::get().to(subscribe::get_subscription_count),
)
.route(
"/{username}/following",
web::get().to(subscribe::get_following_list),
)
.route(
"/{username}/followers/count",
web::get().to(subscribe::get_subscriber_count),

25
libs/api/user/stars.rs Normal file
View File

@ -0,0 +1,25 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use session::Session;
#[utoipa::path(
get,
path = "/api/users/{username}/stars",
params(("username" = String, Path)),
responses(
(status = 200, description = "Get user stars", body = ApiResponse<service::user::stars::UserStarsResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
tag = "User"
)]
pub async fn get_user_stars(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let username = path.into_inner();
let resp = service.get_user_stars(session, username).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -131,3 +131,24 @@ pub async fn get_subscriber_count(
let resp = service.user_get_subscriber_count(session, username).await?;
Ok(ApiResponse::ok(serde_json::json!({ "count": resp })).to_response())
}
#[utoipa::path(
get,
path = "/api/users/{username}/following",
params(("username" = String, Path)),
responses(
(status = 200, description = "List following users", body = ApiResponse<Vec<service::user::subscribe::UserCard>>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
tag = "User"
)]
pub async fn get_following_list(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let username = path.into_inner();
let resp = service.user_get_following_list(session, username).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -0,0 +1,33 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::user::user_activity::UserActivityQuery;
use session::Session;
#[utoipa::path(
get,
path = "/api/users/{username}/activity",
params(
("username" = String, Path),
("page" = Option<u64>, Query),
("per_page" = Option<u64>, Query),
),
responses(
(status = 200, description = "Get user activity", body = ApiResponse<service::user::user_activity::UserActivityResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
tag = "User"
)]
pub async fn get_user_activity(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
query: web::Query<UserActivityQuery>,
) -> Result<HttpResponse, ApiError> {
let username = path.into_inner();
let resp = service
.get_user_activity(session, username, query.into_inner())
.await?;
Ok(ApiResponse::ok(resp).to_response())
}

8
libs/rpc/buf.gen.yaml Normal file
View File

@ -0,0 +1,8 @@
version: v2
managed:
enabled: true
plugins:
- remote: buf.build/bufbuild/es
out: ../../admin/src/lib/adminrpc/generated
- remote: buf.build/bufbuild/connect-es
out: ../../admin/src/lib/adminrpc/generated

9
libs/rpc/buf.yaml Normal file
View File

@ -0,0 +1,9 @@
version: v2
name: buf.build/gitdataai/code
deps:
- buf.build/googleapis/googlerpc
lint:
use:
- DEFAULT
except:
- PACKAGE_VERSION_SUFFIX

View File

@ -121,6 +121,98 @@ message ExportMetricsCsvResponse {
string csv = 1;
}
// ---------------------------------------------------------------------------
// AI Model Sync
// ---------------------------------------------------------------------------
message SyncModelsRequest {}
message SyncModelsResponse {
string body_json = 1; // Serialized SyncModelsResponse JSON
}
// ---------------------------------------------------------------------------
// Billing Alert Check
// ---------------------------------------------------------------------------
message CheckAlertsRequest {}
message CheckAlertsResponse {
string body_json = 1; // Serialized CheckAlertsResponse JSON
}
// ---------------------------------------------------------------------------
// AI Provider CRUD
// ---------------------------------------------------------------------------
message CreateProviderRequest {
string body_json = 1; // Serialized AdminCreateProvider JSON
}
message UpdateProviderRequest {
string id = 1;
string body_json = 2; // Serialized AdminUpdateProvider JSON
}
message DeleteProviderRequest {
string id = 1;
}
message ProviderResponse {
string body_json = 1; // Serialized response JSON
}
// ---------------------------------------------------------------------------
// AI Model CRUD
// ---------------------------------------------------------------------------
message CreateModelRequest {
string body_json = 1; // Serialized AdminCreateModel JSON
}
message UpdateModelRequest {
string id = 1;
string body_json = 2; // Serialized AdminUpdateModel JSON
}
message DeleteModelRequest {
string id = 1;
}
message ModelResponse {
string body_json = 1;
}
// ---------------------------------------------------------------------------
// AI Version CRUD
// ---------------------------------------------------------------------------
message CreateVersionRequest {
string body_json = 1; // Serialized AdminCreateVersion JSON
}
message UpdateVersionRequest {
string id = 1;
string body_json = 2; // Serialized AdminUpdateVersion JSON
}
message DeleteVersionRequest {
string id = 1;
}
message VersionResponse {
string body_json = 1;
}
// ---------------------------------------------------------------------------
// AI Pricing Update
// ---------------------------------------------------------------------------
message UpdatePricingRequest {
string id = 1;
string body_json = 2; // Serialized AdminUpdatePricing JSON
}
message PricingResponse {
string body_json = 1;
}
// ---------------------------------------------------------------------------
// Generic delete response
// ---------------------------------------------------------------------------
message DeleteResponse {
bool deleted = 1;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@ -136,4 +228,21 @@ service SessionAdmin {
rpc IsUserOnline(IsUserOnlineRequest) returns (IsUserOnlineResponse);
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse);
rpc ExportMetricsCsv(ExportMetricsCsvRequest) returns (ExportMetricsCsvResponse);
// AI
rpc SyncModels(SyncModelsRequest) returns (SyncModelsResponse);
rpc CheckAlerts(CheckAlertsRequest) returns (CheckAlertsResponse);
// AI Provider
rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse);
rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse);
rpc DeleteProvider(DeleteProviderRequest) returns (DeleteResponse);
// AI Model
rpc CreateModel(CreateModelRequest) returns (ModelResponse);
rpc UpdateModel(UpdateModelRequest) returns (ModelResponse);
rpc DeleteModel(DeleteModelRequest) returns (DeleteResponse);
// AI Version
rpc CreateVersion(CreateVersionRequest) returns (VersionResponse);
rpc UpdateVersion(UpdateVersionRequest) returns (VersionResponse);
rpc DeleteVersion(DeleteVersionRequest) returns (DeleteResponse);
// AI Pricing
rpc UpdatePricing(UpdatePricingRequest) returns (PricingResponse);
}

View File

@ -8,5 +8,7 @@ pub mod profile;
pub mod projects;
pub mod repository;
pub mod ssh_key;
pub mod stars;
pub mod subscribe;
pub mod user_activity;
pub mod user_info;

View File

@ -60,25 +60,55 @@ impl AppService {
let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100);
let offset = (page - 1) * per_page;
let mut condition = Condition::all().add(project::Column::CreatedBy.eq(target_user.uid));
if !is_owner && !has_admin_privilege {
condition = condition.add(project::Column::IsPublic.eq(true));
}
let total_count = project::Entity::find()
.filter(condition.clone())
.count(&self.db)
.await?;
let project_list = project::Entity::find()
.filter(condition)
.order_by_desc(project::Column::CreatedAt)
.limit(per_page)
.offset(offset)
// Projects where user is the creator
let created_projects: Vec<Uuid> = project::Entity::find()
.filter(project::Column::CreatedBy.eq(target_user.uid))
.select_only()
.column(project::Column::Id)
.into_tuple::<Uuid>()
.all(&self.db)
.await?;
// Projects where user is a member (via invitation)
let member_projects: Vec<Uuid> = project_members::Entity::find()
.filter(project_members::Column::User.eq(target_user.uid))
.select_only()
.column(project_members::Column::Project)
.into_tuple::<Uuid>()
.all(&self.db)
.await?;
// Union + dedup (preserving first occurrence order)
let mut project_ids: Vec<Uuid> = created_projects;
let new_ids: Vec<Uuid> = member_projects.into_iter().filter(|id| !project_ids.contains(id)).collect();
project_ids.extend(new_ids);
let total_count = project_ids.len() as u64;
// Paginate
let page_ids: Vec<Uuid> = project_ids.into_iter().skip(offset as usize).take(per_page as usize).collect();
if page_ids.is_empty() {
return Ok(UserProjectsResponse {
username: target_user.username,
projects: vec![],
total_count,
});
}
let project_list: Vec<project::Model> = project::Entity::find()
.filter(project::Column::Id.is_in(page_ids.clone()))
.all(&self.db)
.await?;
// Preserve the order from project_ids (created projects first, then member projects)
let mut sorted_projects = project_list;
sorted_projects.sort_by(|a, b| {
let a_idx = page_ids.iter().position(|&x| x == a.id).unwrap_or(usize::MAX);
let b_idx = page_ids.iter().position(|&x| x == b.id).unwrap_or(usize::MAX);
a_idx.cmp(&b_idx)
});
let user_project_memberships: std::collections::HashSet<Uuid> =
if let Some(uid) = current_user_uid {
project_members::Entity::find()
@ -95,7 +125,11 @@ impl AppService {
};
let mut project_infos: Vec<UserProjectInfo> = Vec::new();
for project in project_list {
for project in sorted_projects {
// Privacy: non-owners/non-admins only see public projects (member or created)
if !is_owner && !has_admin_privilege && !project.is_public {
continue;
}
let member_count = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.count(&self.db)

152
libs/service/user/stars.rs Normal file
View File

@ -0,0 +1,152 @@
use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc};
use models::projects::{project, project_follow};
use models::repos::{repo, repo_star};
use models::users::user;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RepoStarItem {
pub uid: Uuid,
pub repo_name: String,
pub owner: String,
pub description: Option<String>,
pub is_private: bool,
pub default_branch: String,
pub starred_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProjectFollowItem {
pub uid: Uuid,
pub name: String,
pub display_name: String,
pub description: Option<String>,
pub is_public: bool,
pub followed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserStarsResponse {
pub repos: Vec<RepoStarItem>,
pub projects: Vec<ProjectFollowItem>,
pub total: u64,
}
impl AppService {
pub async fn get_user_stars(
&self,
context: Session,
username: String,
) -> Result<UserStarsResponse, AppError> {
let target_user = user::Entity::find()
.filter(user::Column::Username.eq(&username))
.one(&self.db)
.await?
.ok_or(AppError::UserNotFound)?;
let current_uid = context.user();
let is_owner = current_uid
.map(|uid| uid == target_user.uid)
.unwrap_or(false);
// Repo stars
let stars = repo_star::Entity::find()
.filter(repo_star::Column::User.eq(target_user.uid))
.order_by_desc(repo_star::Column::CreatedAt)
.all(&self.db)
.await?;
let repo_ids: Vec<Uuid> = stars.iter().map(|s| s.repo).collect();
let repos: std::collections::HashMap<Uuid, repo::Model> = repo::Entity::find()
.filter(repo::Column::Id.is_in(repo_ids.clone()))
.all(&self.db)
.await?
.into_iter()
.map(|r| (r.id, r))
.collect();
let project_ids: Vec<Uuid> = repos.values().map(|r| r.project).collect();
let projects_map: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
.filter(project::Column::Id.is_in(project_ids))
.all(&self.db)
.await?
.into_iter()
.map(|p| (p.id, p))
.collect();
// Build repo items with privacy filter
let mut repo_items: Vec<RepoStarItem> = Vec::new();
for star in &stars {
if let Some(r) = repos.get(&star.repo) {
// Privacy: non-owners can only see public repos
if !is_owner && r.is_private {
continue;
}
let owner_username = if let Some(p) = projects_map.get(&r.project) {
p.name.clone()
} else {
String::new()
};
repo_items.push(RepoStarItem {
uid: r.id,
repo_name: r.repo_name.clone(),
owner: owner_username,
description: r.description.clone(),
is_private: r.is_private,
default_branch: r.default_branch.clone(),
starred_at: star.created_at,
});
}
}
// Project follows
let follows = project_follow::Entity::find()
.filter(project_follow::Column::User.eq(target_user.uid))
.order_by_desc(project_follow::Column::CreatedAt)
.all(&self.db)
.await?;
let followed_project_ids: Vec<Uuid> = follows.iter().map(|f| f.project).collect();
let followed_projects: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
.filter(project::Column::Id.is_in(followed_project_ids))
.all(&self.db)
.await?
.into_iter()
.map(|p| (p.id, p))
.collect();
let mut project_items: Vec<ProjectFollowItem> = Vec::new();
for follow in &follows {
if let Some(p) = followed_projects.get(&follow.project) {
// Privacy: non-owners can only see public projects
if !is_owner && !p.is_public {
continue;
}
project_items.push(ProjectFollowItem {
uid: p.id,
name: p.name.clone(),
display_name: p.display_name.clone(),
description: p.description.clone(),
is_public: p.is_public,
followed_at: follow.created_at,
});
}
}
let total = repo_items.len() as u64 + project_items.len() as u64;
Ok(UserStarsResponse {
repos: repo_items,
projects: project_items,
total,
})
}
}

View File

@ -15,6 +15,15 @@ pub struct SubscriptionInfo {
pub is_active: bool,
}
#[derive(serde::Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UserCard {
pub user_uid: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub is_following_me: bool,
}
impl From<user_relation::Model> for SubscriptionInfo {
fn from(sub: user_relation::Model) -> Self {
SubscriptionInfo {
@ -154,4 +163,63 @@ impl AppService {
Ok(count)
}
pub async fn user_get_following_list(
&self,
context: Session,
username: String,
) -> Result<Vec<UserCard>, AppError> {
let target_user = self.utils_find_user_by_username(username).await?;
let target_uid = target_user.uid;
let current_uid = context.user();
let following = user_relation::Entity::find()
.filter(user_relation::Column::User.eq(target_uid))
.filter(user_relation::Column::RelationType.eq("follow"))
.order_by_desc(user_relation::Column::CreatedAt)
.all(&self.db)
.await?;
let followed_uids: Vec<Uuid> = following.iter().map(|f| f.target).collect();
let followed_users: std::collections::HashMap<Uuid, models::users::user::Model> = models::users::user::Entity::find()
.filter(models::users::user::Column::Uid.is_in(followed_uids.clone()))
.all(&self.db)
.await?
.into_iter()
.map(|u| (u.uid, u))
.collect();
// If current user is logged in, check who they also follow
let current_follows: std::collections::HashSet<Uuid> = if let Some(uid) = current_uid {
user_relation::Entity::find()
.filter(user_relation::Column::User.eq(uid))
.filter(user_relation::Column::Target.is_in(followed_uids.clone()))
.filter(user_relation::Column::RelationType.eq("follow"))
.select_only()
.column(user_relation::Column::Target)
.into_tuple::<Uuid>()
.all(&self.db)
.await?
.into_iter()
.collect()
} else {
std::collections::HashSet::new()
};
let mut cards: Vec<UserCard> = Vec::new();
for rel in following {
if let Some(user) = followed_users.get(&rel.target) {
cards.push(UserCard {
user_uid: user.uid,
username: user.username.clone(),
display_name: user.display_name.clone(),
avatar_url: user.avatar_url.clone(),
is_following_me: current_follows.contains(&user.uid),
});
}
}
Ok(cards)
}
}

View File

@ -0,0 +1,173 @@
use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc};
use models::projects::{project, project_activity};
use models::users::{user, user_activity_log};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserActivityItem {
pub id: i64,
pub activity_type: String, // "auth" | "project"
pub action: String,
pub title: String,
pub resource_type: Option<String>,
pub resource_name: Option<String>,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserActivityResponse {
pub items: Vec<UserActivityItem>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::IntoParams)]
pub struct UserActivityQuery {
pub page: Option<u64>,
pub per_page: Option<u64>,
}
impl AppService {
pub async fn get_user_activity(
&self,
context: Session,
username: String,
query: UserActivityQuery,
) -> Result<UserActivityResponse, AppError> {
let target_user = user::Entity::find()
.filter(user::Column::Username.eq(&username))
.one(&self.db)
.await?
.ok_or(AppError::UserNotFound)?;
let current_uid = context.user();
let is_owner = current_uid
.map(|uid| uid == target_user.uid)
.unwrap_or(false);
let page = std::cmp::Ord::max(query.page.unwrap_or(1), 1);
let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100);
let offset = (page - 1) * per_page;
// User's auth activity log entries
let auth_logs: Vec<(i64, String, DateTime<Utc>, serde_json::Value)> =
user_activity_log::Entity::find()
.filter(user_activity_log::Column::UserUid.eq(target_user.uid))
.order_by_desc(user_activity_log::Column::CreatedAt)
.select_only()
.column(user_activity_log::Column::Id)
.column(user_activity_log::Column::Action)
.column(user_activity_log::Column::CreatedAt)
.column(user_activity_log::Column::Details)
.into_tuple()
.all(&self.db)
.await?;
// User's project activity (where user is the actor)
let proj_activity = project_activity::Entity::find()
.filter(project_activity::Column::Actor.eq(target_user.uid))
.order_by_desc(project_activity::Column::CreatedAt)
.all(&self.db)
.await?;
// Privacy filter: non-owners only see public project activity
let proj_ids: Vec<Uuid> = proj_activity.iter().map(|a| a.project).collect();
let proj_map: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
.filter(project::Column::Id.is_in(proj_ids))
.all(&self.db)
.await?
.into_iter()
.map(|p| (p.id, p))
.collect();
// Build combined activity list
let mut items: Vec<UserActivityItem> = Vec::new();
// Auth events
for (id, action, created_at, metadata) in auth_logs {
let title = format_action_title(&action);
items.push(UserActivityItem {
id,
activity_type: "auth".to_string(),
action,
title,
resource_type: None,
resource_name: None,
metadata: if metadata != serde_json::Value::Null {
Some(metadata)
} else {
None
},
created_at,
});
}
// Project events
for activity in proj_activity {
// Privacy filter
if !is_owner {
if let Some(proj) = proj_map.get(&activity.project) {
if proj.is_public == false {
continue;
}
} else {
continue;
}
}
items.push(UserActivityItem {
id: activity.id,
activity_type: "project".to_string(),
action: activity.event_type,
title: activity.title,
resource_type: Some("project".to_string()),
resource_name: proj_map
.get(&activity.project)
.map(|p| p.name.clone()),
metadata: activity.metadata,
created_at: activity.created_at,
});
}
// Sort by created_at DESC
items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let total = items.len() as u64;
let page_items: Vec<UserActivityItem> = items.into_iter().skip(offset as usize).take(per_page as usize).collect();
Ok(UserActivityResponse {
items: page_items,
total,
page,
per_page,
})
}
}
fn format_action_title(action: &str) -> String {
match action {
"login" => "Signed in".to_string(),
"logout" => "Signed out".to_string(),
"register" => "Created account".to_string(),
"password_change" => "Changed password".to_string(),
"password_reset" => "Reset password".to_string(),
"2fa_enabled" => "Enabled 2FA".to_string(),
"2fa_disabled" => "Disabled 2FA".to_string(),
"ssh_key_add" => "Added SSH key".to_string(),
"ssh_key_delete" => "Removed SSH key".to_string(),
"ssh_key_update" => "Updated SSH key".to_string(),
"ssh_key_revoke" => "Revoked SSH key".to_string(),
"access_key_create" => "Created access token".to_string(),
"access_key_delete" => "Deleted access token".to_string(),
"avatar_upload" => "Updated avatar".to_string(),
"profile_update" => "Updated profile".to_string(),
_ => format!("Activity: {}", action),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ const TAG = process.env.TAG || GIT_SHA_SHORT;
const DOCKER_USER = process.env.DOCKER_USER || process.env.HARBOR_USERNAME;
const DOCKER_PASS = process.env.DOCKER_PASS || process.env.HARBOR_PASSWORD;
const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static'];
const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static', 'adminrpc'];
const args = process.argv.slice(2);
const targets = args.length > 0 ? args : SERVICES;

View File

@ -0,0 +1,78 @@
import { useQuery } from '@tanstack/react-query';
import { Activity, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getUserActivity, type UserActivityItem } from '@/client';
import { formatDate } from './utils';
export function UserActivity({ username }: { username: string }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['user-activity', username],
queryFn: async () => {
const resp = await getUserActivity({ path: { username } });
return resp.data?.data ?? null;
},
retry: false,
});
if (isLoading) {
return (
<Card className="border-border/40">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Loading activity...
</CardContent>
</Card>
);
}
if (isError || !data) {
return (
<Card className="border-border/40">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Unable to load activity.
</CardContent>
</Card>
);
}
if (data.items.length === 0) {
return (
<Card className="border-border/40">
<CardContent className="py-12 text-center">
<Activity className="mx-auto h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No activity yet</p>
</CardContent>
</Card>
);
}
const getTypeColor = (type: string) => {
if (type === 'auth') return 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400';
return 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400';
};
return (
<div className="space-y-3">
{data.items.map((item: UserActivityItem) => (
<Card key={`${item.activity_type}-${item.id}`} className="border-border/40">
<CardContent className="flex items-start gap-3 py-4">
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${getTypeColor(item.activity_type)}`}>
{item.activity_type === 'auth' ? 'A' : 'P'}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.title}</p>
{item.resource_name && (
<p className="text-xs text-muted-foreground mt-0.5">
Project: {item.resource_name}
</p>
)}
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground/70">
<Clock className="h-3 w-3" />
{formatDate(item.created_at)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,71 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
import { getFollowingList } from '@/client';
import { UserPlus } from 'lucide-react';
export function FollowingList({ username }: { username: string }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['user-following', username],
queryFn: async () => {
const resp = await getFollowingList({ path: { username } });
return resp.data?.data ?? [];
},
retry: false,
});
if (isLoading) {
return (
<Card className="border-border/40">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Loading following...
</CardContent>
</Card>
);
}
if (isError || !data) {
return (
<Card className="border-border/40">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Unable to load following.
</CardContent>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="border-border/40">
<CardContent className="py-12 text-center">
<UserPlus className="mx-auto h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">Not following anyone yet</p>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{data.map((user: { user_uid: string; username: string; display_name?: string | null; avatar_url?: string | null }) => (
<Link
key={user.user_uid}
to={`/user/${user.username}`}
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
>
<Avatar className="h-10 w-10">
<AvatarImage src={user.avatar_url ?? undefined} />
<AvatarFallback className="text-sm">
{(user.display_name || user.username).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.display_name || user.username}</p>
<p className="truncate text-xs text-muted-foreground">@{user.username}</p>
</div>
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,149 @@
import { useQuery } from '@tanstack/react-query';
import { Copy, Key, Shield, Loader2, AlertTriangle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { listSshKeys, listAccessKeys } from '@/client';
import { toast } from 'sonner';
export function SecurityTab() {
const { data: sshKeys, isLoading: sshLoading } = useQuery({
queryKey: ['my-ssh-keys'],
queryFn: async () => {
const resp = await listSshKeys();
return resp.data?.data?.keys ?? [];
},
retry: false,
});
const { data: accessKeys, isLoading: accessLoading } = useQuery({
queryKey: ['my-access-keys'],
queryFn: async () => {
const resp = await listAccessKeys();
return resp.data?.data?.access_keys ?? [];
},
retry: false,
});
const isLoading = sshLoading || accessLoading;
const copyFingerprint = (fingerprint: string) => {
navigator.clipboard.writeText(fingerprint).then(() => {
toast.success('Copied fingerprint');
});
};
if (isLoading) {
return (
<Card className="border-border/40">
<CardContent className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading security settings...
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* SSH Keys */}
<Card className="border-border/40">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Key className="h-4 w-4" />
SSH Keys
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{(sshKeys ?? []).length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">No SSH keys added</p>
) : (
(sshKeys ?? []).map((key: {
id: number;
title: string;
fingerprint: string;
key_type: string;
is_revoked: boolean;
created_at: string;
}) => (
<div
key={key.id}
className="flex items-center justify-between rounded-lg border border-border/40 p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{key.title}</p>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase">{key.key_type}</span>
{key.is_revoked && (
<span className="flex items-center gap-1 text-[10px] text-red-500">
<AlertTriangle className="h-3 w-3" /> Revoked
</span>
)}
</div>
<button
onClick={() => copyFingerprint(key.fingerprint)}
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{key.fingerprint}
<Copy className="h-3 w-3" />
</button>
</div>
</div>
))
)}
<Button variant="outline" size="sm" className="mt-2 w-full" onClick={() => window.location.href = '/settings/keys'}>
Manage SSH Keys
</Button>
</CardContent>
</Card>
{/* Personal Access Tokens */}
<Card className="border-border/40">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Shield className="h-4 w-4" />
Personal Access Tokens
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{(accessKeys ?? []).length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">No access tokens created</p>
) : (
(accessKeys ?? []).map((key: {
id: number;
name: string;
scopes: string[];
is_revoked: boolean;
created_at: string;
}) => (
<div
key={key.id}
className="flex items-center justify-between rounded-lg border border-border/40 p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{key.name}</p>
{key.is_revoked && (
<span className="flex items-center gap-1 text-[10px] text-red-500">
<AlertTriangle className="h-3 w-3" /> Revoked
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{(key.scopes ?? []).map((scope: string) => (
<span key={scope} className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{scope}
</span>
))}
</div>
</div>
</div>
))
)}
<Button variant="outline" size="sm" className="mt-2 w-full" onClick={() => window.location.href = '/settings/tokens'}>
Manage Access Tokens
</Button>
</CardContent>
</Card>
</div>
);
}

107
src/app/user/user-stars.tsx Normal file
View File

@ -0,0 +1,107 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Star, FolderGit2, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getUserStars } from '@/client';
export function StarsList({ username }: { username: string }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['user-stars', username],
queryFn: async () => {
const resp = await getUserStars({ path: { username } });
return resp.data?.data ?? null;
},
retry: false,
});
if (isLoading) {
return (
<Card className="border-border/40">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
Loading stars...
</CardContent>
</Card>
);
}
if (isError || !data) {
return (
<Card className="border-border/40">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Unable to load stars.
</CardContent>
</Card>
);
}
const hasRepos = (data.repos ?? []).length > 0;
const hasProjects = (data.projects ?? []).length > 0;
if (!hasRepos && !hasProjects) {
return (
<Card className="border-border/40">
<CardContent className="py-12 text-center">
<Star className="mx-auto h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No starred repos or projects yet</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{hasRepos && (
<div>
<h3 className="mb-3 text-sm font-semibold text-foreground">Repositories</h3>
<div className="space-y-2">
{(data.repos ?? []).map((repo: { uid: string; repo_name: string; owner: string; description?: string | null; is_private: boolean }) => (
<Link
key={repo.uid}
to={`/repository/${repo.owner}/${repo.repo_name}`}
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
>
<Star className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{repo.owner}/{repo.repo_name}</p>
{repo.description && (
<p className="truncate text-xs text-muted-foreground">{repo.description}</p>
)}
</div>
{repo.is_private && (
<span className="shrink-0 rounded border border-border/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">Private</span>
)}
</Link>
))}
</div>
</div>
)}
{hasProjects && (
<div>
<h3 className="mb-3 text-sm font-semibold text-foreground">Projects</h3>
<div className="space-y-2">
{(data.projects ?? []).map((proj: { uid: string; name: string; display_name: string; description?: string | null; is_public: boolean }) => (
<Link
key={proj.uid}
to={`/project/${proj.name}`}
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
>
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{proj.display_name}</p>
{proj.description && (
<p className="truncate text-xs text-muted-foreground">{proj.description}</p>
)}
</div>
{!proj.is_public && (
<span className="shrink-0 rounded border border-border/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">Private</span>
)}
</Link>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,8 @@
import { useContext } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Activity,
AlertCircle,
Building2,
Calendar,
@ -10,6 +11,7 @@ import {
Loader2,
MapPin,
Settings,
Shield,
Star,
UserPlus,
UserRoundCheck,
@ -22,6 +24,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { getContributionHeatmap, getSubscriberCount, getUserInfo, getUserProjects, getUserRepos, subscribeTarget, unsubscribeTarget } from '@/client';
import { UserContext } from '@/contexts/user-context';
import { UserActivity } from './user-activity';
import { FollowingList } from './user-following';
import { StarsList } from './user-stars';
import { SecurityTab } from './user-security';
import { formatDate } from './utils';
const resolveCount = (payload: unknown): number => {
if (typeof payload === 'number') return payload;
@ -43,17 +50,6 @@ const resolveCount = (payload: unknown): number => {
return 0;
};
const formatDate = (value?: string | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const resolveHeatLevel = (count: number, max: number) => {
if (count <= 0) return 0;
if (max <= 1) return 4;
@ -246,6 +242,10 @@ export function UserProfile() {
const isAuth = currentUser !== null;
const queryClient = useQueryClient();
const nav = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') || 'overview';
const setTab = (tab: string) => setSearchParams({ tab });
const userInfoKey = ['user-info', targetUser] as const;
const subscriberCountKey = ['user-subscriber-count', targetUser] as const;
@ -537,7 +537,83 @@ export function UserProfile() {
</div>
</section>
{/* Contribution Heatmap */}
{/* Tab Navigation */}
<div className="flex items-center gap-1 border-b border-border/40 -mb-2">
<button
onClick={() => setTab('overview')}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'overview'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<FolderGit2 className="h-4 w-4" />
Overview
</button>
<button
onClick={() => setTab('activity')}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'activity'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Activity className="h-4 w-4" />
Activity
</button>
{/*
<button
onClick={() => setTab('followers')}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'followers'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<UserCheck className="h-4 w-4" />
Followers
</button>
*/}
<button
onClick={() => setTab('following')}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'following'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<UserPlus className="h-4 w-4" />
Following
</button>
<button
onClick={() => setTab('stars')}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'stars'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Star className="h-4 w-4" />
Stars
</button>
{userInfo.is_owner && (
<button
onClick={() => setTab('security')}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'security'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Shield className="h-4 w-4" />
Security
</button>
)}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div>{/* Contribution Heatmap */}
<ContributionHeatmap
totalContributions={contributionHeatmap?.total_contributions ?? 0}
heatmap={contributionHeatmap?.heatmap ?? []}
@ -631,6 +707,20 @@ export function UserProfile() {
</CardContent>
</Card>
</section>
</div>
)}
{activeTab === 'activity' && (
<UserActivity username={targetUser} />
)}
{activeTab === 'following' && (
<FollowingList username={targetUser} />
)}
{activeTab === 'stars' && (
<StarsList username={targetUser} />
)}
{activeTab === 'security' && userInfo.is_owner && (
<SecurityTab />
)}
</main>
</div>
);

10
src/app/user/utils.ts Normal file
View File

@ -0,0 +1,10 @@
export const formatDate = (value?: string | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -56,6 +56,15 @@ export type AddSshKeyParams = {
public_key: string;
};
export type AlertDetail = {
workspace_id: string;
workspace_name: string;
alert_type: string;
threshold: number;
current_value: number;
recipients: Array<string>;
};
export type AnswerRequest = {
question: string;
answer: string;
@ -478,6 +487,16 @@ export type ApiResponseCardResponse = {
};
};
export type ApiResponseCheckAlertsResponse = {
code: number;
message: string;
data?: {
workspaces_checked: number;
alerts_sent: number;
details: Array<AlertDetail>;
};
};
export type ApiResponseColumnResponse = {
code: number;
message: string;
@ -1467,6 +1486,7 @@ export type ApiResponseRoomAiResponse = {
think: boolean;
stream: boolean;
min_score?: number | null;
agent_type?: string | null;
created_at: string;
updated_at: string;
};
@ -1529,6 +1549,11 @@ export type ApiResponseRoomMessageResponse = {
send_at: string;
revoked?: string | null;
revoked_by?: string | null;
/**
* Highlighted content with <mark> tags around matched terms (for search results)
*/
highlighted_content?: string | null;
attachment_ids?: Array<string>;
};
};
@ -1680,6 +1705,19 @@ export type ApiResponseString = {
data?: string;
};
export type ApiResponseSyncModelsResponse = {
code: number;
message: string;
data?: {
models_created: number;
models_updated: number;
versions_created: number;
pricing_created: number;
capabilities_created: number;
profiles_created: number;
};
};
export type ApiResponseTagCountResponse = {
code: number;
message: string;
@ -1828,6 +1866,17 @@ export type ApiResponseTreeIsEmptyResponse = {
};
};
export type ApiResponseUserActivityResponse = {
code: number;
message: string;
data?: {
items: Array<UserActivityItem>;
total: number;
page: number;
per_page: number;
};
};
export type ApiResponseUserInfoExternal = {
code: number;
message: string;
@ -1869,6 +1918,16 @@ export type ApiResponseUserReposResponse = {
};
};
export type ApiResponseUserStarsResponse = {
code: number;
message: string;
data?: {
repos: Array<RepoStarItem>;
projects: Array<ProjectFollowItem>;
total: number;
};
};
export type ApiResponseValue = {
code: number;
message: string;
@ -2075,6 +2134,20 @@ export type ApiResponseVecMergeheadInfoResponse = {
}>;
};
export type ApiResponseVecMyWorkspaceInvitation = {
code: number;
message: string;
data?: Array<{
workspace_id: string;
workspace_slug: string;
workspace_name: string;
role: string;
invited_by_username?: string | null;
invited_at: string;
expires_at?: string | null;
}>;
};
export type ApiResponseVecPendingInvitationInfo = {
code: number;
message: string;
@ -2124,6 +2197,7 @@ export type ApiResponseVecRoomAiResponse = {
think: boolean;
stream: boolean;
min_score?: number | null;
agent_type?: string | null;
created_at: string;
updated_at: string;
}>;
@ -2268,6 +2342,18 @@ export type ApiResponseVecTreeEntryResponse = {
}>;
};
export type ApiResponseVecUserCard = {
code: number;
message: string;
data?: Array<{
user_uid: string;
username: string;
display_name?: string | null;
avatar_url?: string | null;
is_following_me: boolean;
}>;
};
export type ApiResponseVecWatchUserInfo = {
code: number;
message: string;
@ -2722,6 +2808,12 @@ export type ChangePasswordParams = {
new_password: string;
};
export type CheckAlertsResponse = {
workspaces_checked: number;
alerts_sent: number;
details: Array<AlertDetail>;
};
export type ColumnResponse = {
id: string;
board: string;
@ -2992,6 +3084,11 @@ export type ConfigSnapshotResponse = {
entries: Array<ConfigEntryResponse>;
};
export type ConfirmResetPasswordParams = {
token: string;
new_password: string;
};
export type ContextMe = {
uid: string;
username: string;
@ -3199,11 +3296,6 @@ export type DiffStatsResponse = {
deletions: number;
};
export type Disable2FaParams = {
code: string;
password: string;
};
export type EmailChangeRequest = {
new_email: string;
password: string;
@ -3217,12 +3309,6 @@ export type EmailVerifyRequest = {
token: string;
};
export type Enable2FaResponse = {
secret: string;
qr_code: string;
backup_codes: Array<string>;
};
export type ExchangeProjectName = {
name: string;
};
@ -3262,12 +3348,6 @@ export type GeneratePrDescriptionResponse = {
billing?: null | BillingRecord;
};
export type Get2FaStatusResponse = {
is_enabled: boolean;
method?: string | null;
has_backup_codes: boolean;
};
export type GitInitRequest = {
path: string;
bare?: boolean;
@ -3552,7 +3632,6 @@ export type LoginParams = {
username: string;
password: string;
captcha: string;
totp_code?: string | null;
};
export type MemberInfo = {
@ -3742,6 +3821,19 @@ export type MoveCardParams = {
position: number;
};
/**
* Invitation received by the current user (workspace invitation for self).
*/
export type MyWorkspaceInvitation = {
workspace_id: string;
workspace_slug: string;
workspace_name: string;
role: string;
invited_by_username?: string | null;
invited_at: string;
expires_at?: string | null;
};
export type NotificationListResponse = {
notifications: Array<NotificationResponse>;
total: number;
@ -3759,6 +3851,18 @@ export type NotificationPreferencesParams = {
marketing_enabled?: boolean | null;
security_enabled?: boolean | null;
product_enabled?: boolean | null;
/**
* Web Push subscription endpoint (set to null to unsubscribe)
*/
push_subscription_endpoint?: string | null;
/**
* Web Push subscription p256dh key
*/
push_subscription_keys_p256dh?: string | null;
/**
* Web Push subscription auth key
*/
push_subscription_keys_auth?: string | null;
};
export type NotificationPreferencesResponse = {
@ -3797,7 +3901,7 @@ export type NotificationResponse = {
expires_at?: string | null;
};
export type NotificationType = 'mention' | 'invitation' | 'role_change' | 'room_created' | 'room_deleted' | 'system_announcement';
export type NotificationType = 'mention' | 'invitation' | 'role_change' | 'room_created' | 'room_deleted' | 'system_announcement' | 'project_invitation' | 'workspace_invitation';
export type Pager = {
page: number;
@ -3816,16 +3920,6 @@ export type PendingInvitationInfo = {
expires_at?: string | null;
};
export type MyWorkspaceInvitation = {
workspace_id: string;
workspace_slug: string;
workspace_name: string;
role: string;
invited_by_username?: string | null;
invited_at: string;
expires_at?: string | null;
};
export type PrCommitResponse = {
oid: string;
short_oid: string;
@ -3936,6 +4030,15 @@ export type ProjectBillingHistoryResponse = {
list: Array<ProjectBillingHistoryItem>;
};
export type ProjectFollowItem = {
uid: string;
name: string;
display_name: string;
description?: string | null;
is_public: boolean;
followed_at: string;
};
export type ProjectInfoKeyValue = {
key: string;
value: string;
@ -4201,13 +4304,18 @@ export type RepoSearchItem = {
created_at: string;
};
export type ResetPasswordParams = {
email: string;
export type RepoStarItem = {
uid: string;
repo_name: string;
owner: string;
description?: string | null;
is_private: boolean;
default_branch: string;
starred_at: string;
};
export type ConfirmResetPasswordParams = {
token: string;
new_password: string;
export type ResetPasswordParams = {
email: string;
};
export type ReviewCommentCreateRequest = {
@ -4348,7 +4456,7 @@ export type ReviewerInfo = {
export type RoomAiResponse = {
room: string;
model: string;
model_name?: string;
model_name?: string | null;
version?: string | null;
call_count: number;
last_call_at?: string | null;
@ -4436,7 +4544,7 @@ export type RoomMessageCreateRequest = {
content_type?: string | null;
thread_id?: string | null;
in_reply_to?: string | null;
attachment_ids?: string[];
attachment_ids?: Array<string>;
};
export type RoomMessageListResponse = {
@ -4459,7 +4567,11 @@ export type RoomMessageResponse = {
send_at: string;
revoked?: string | null;
revoked_by?: string | null;
attachment_ids?: string[];
/**
* Highlighted content with <mark> tags around matched terms (for search results)
*/
highlighted_content?: string | null;
attachment_ids?: Array<string>;
};
export type RoomMessageUpdateRequest = {
@ -4700,6 +4812,15 @@ export type SubscriptionInfo = {
is_active: boolean;
};
export type SyncModelsResponse = {
models_created: number;
models_updated: number;
versions_created: number;
pricing_created: number;
capabilities_created: number;
profiles_created: number;
};
export type TagCountResponse = {
count: number;
};
@ -4945,6 +5066,32 @@ export type UpdateWebhookParams = {
active?: boolean | null;
};
export type UserActivityItem = {
id: number;
activity_type: string;
action: string;
title: string;
resource_type?: string | null;
resource_name?: string | null;
metadata?: unknown;
created_at: string;
};
export type UserActivityResponse = {
items: Array<UserActivityItem>;
total: number;
page: number;
per_page: number;
};
export type UserCard = {
user_uid: string;
username: string;
display_name?: string | null;
avatar_url?: string | null;
is_following_me: boolean;
};
export type UserInfo = {
uid: string;
username: string;
@ -5023,8 +5170,10 @@ export type UserSearchItem = {
created_at: string;
};
export type Verify2FaParams = {
code: string;
export type UserStarsResponse = {
repos: Array<RepoStarItem>;
projects: Array<ProjectFollowItem>;
total: number;
};
export type WatchCountResponse = {
@ -5067,6 +5216,13 @@ export type WebhookResponse = {
touch_count: number;
};
/**
* Request body for accepting workspace invitation by slug.
*/
export type WorkspaceAcceptBySlugParams = {
slug: string;
};
export type WorkspaceActivityItem = {
id: number;
project_name: string;
@ -5148,10 +5304,6 @@ export type WorkspaceInviteParams = {
role?: string | null;
};
export type WorkspaceAcceptBySlugParams = {
slug: string;
};
export type WorkspaceListItem = {
id: string;
slug: string;
@ -5225,6 +5377,92 @@ export type WorkspaceUpdateParams = {
billing_email?: string | null;
};
export type AdminSyncModelsData = {
body?: never;
path?: never;
query?: never;
url: '/api/admin/ai/sync';
};
export type AdminSyncModelsErrors = {
/**
* Invalid or missing admin API key
*/
401: unknown;
/**
* Sync failed
*/
500: unknown;
};
export type AdminSyncModelsResponses = {
/**
* Sync result
*/
200: ApiResponseSyncModelsResponse;
};
export type AdminSyncModelsResponse = AdminSyncModelsResponses[keyof AdminSyncModelsResponses];
export type AdminCheckAlertsData = {
body?: never;
path?: never;
query?: never;
url: '/api/admin/alerts/check';
};
export type AdminCheckAlertsErrors = {
/**
* Invalid or missing admin API key
*/
401: unknown;
};
export type AdminCheckAlertsResponses = {
/**
* Alert check result
*/
200: ApiResponseCheckAlertsResponse;
};
export type AdminCheckAlertsResponse = AdminCheckAlertsResponses[keyof AdminCheckAlertsResponses];
export type AdminWorkspaceAddCreditData = {
body: WorkspaceBillingAddCreditParams;
path: {
/**
* Workspace slug
*/
slug: string;
};
query?: never;
url: '/api/admin/workspaces/{slug}/add-credit';
};
export type AdminWorkspaceAddCreditErrors = {
/**
* Invalid amount
*/
400: unknown;
/**
* Invalid or missing admin API key
*/
401: unknown;
/**
* Workspace not found
*/
404: unknown;
};
export type AdminWorkspaceAddCreditResponses = {
/**
* Credit added
*/
200: ApiResponseWorkspaceBillingCurrentResponse;
};
export type AdminWorkspaceAddCreditResponse = AdminWorkspaceAddCreditResponses[keyof AdminWorkspaceAddCreditResponses];
export type ModelCapabilityCreateData = {
body: CreateModelCapabilityRequest;
path?: never;
@ -5917,146 +6155,6 @@ export type ModelPricingListResponses = {
export type ModelPricingListResponse = ModelPricingListResponses[keyof ModelPricingListResponses];
export type Api2FaDisableData = {
body: Disable2FaParams;
path?: never;
query?: never;
url: '/api/auth/2fa/disable';
};
export type Api2FaDisableErrors = {
/**
* 2FA not enabled or invalid code/password
*/
400: unknown;
/**
* Unauthorized
*/
401: unknown;
/**
* Not found
*/
404: ApiResponseApiError;
/**
* Internal server error
*/
500: unknown;
};
export type Api2FaDisableError = Api2FaDisableErrors[keyof Api2FaDisableErrors];
export type Api2FaDisableResponses = {
/**
* 2FA disabled
*/
200: unknown;
};
export type Api2FaEnableData = {
body?: never;
path?: never;
query?: never;
url: '/api/auth/2fa/enable';
};
export type Api2FaEnableErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Not found
*/
404: ApiResponseApiError;
/**
* 2FA already enabled
*/
409: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type Api2FaEnableError = Api2FaEnableErrors[keyof Api2FaEnableErrors];
export type Api2FaEnableResponses = {
/**
* 2FA setup initiated
*/
200: Enable2FaResponse;
};
export type Api2FaEnableResponse = Api2FaEnableResponses[keyof Api2FaEnableResponses];
export type Api2FaStatusData = {
body?: never;
path?: never;
query?: never;
url: '/api/auth/2fa/status';
};
export type Api2FaStatusErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Not found
*/
404: ApiResponseApiError;
/**
* Internal server error
*/
500: unknown;
};
export type Api2FaStatusError = Api2FaStatusErrors[keyof Api2FaStatusErrors];
export type Api2FaStatusResponses = {
/**
* 2FA status
*/
200: Get2FaStatusResponse;
};
export type Api2FaStatusResponse = Api2FaStatusResponses[keyof Api2FaStatusResponses];
export type Api2FaVerifyData = {
body: Verify2FaParams;
path?: never;
query?: never;
url: '/api/auth/2fa/verify';
};
export type Api2FaVerifyErrors = {
/**
* 2FA not set up
*/
400: unknown;
/**
* Unauthorized or invalid code
*/
401: unknown;
/**
* Not found
*/
404: ApiResponseApiError;
/**
* Internal server error
*/
500: unknown;
};
export type Api2FaVerifyError = Api2FaVerifyErrors[keyof Api2FaVerifyErrors];
export type Api2FaVerifyResponses = {
/**
* 2FA verified and enabled
*/
200: unknown;
};
export type ApiAuthCaptchaData = {
body: CaptchaQuery;
path?: never;
@ -6299,39 +6397,6 @@ export type ApiUserChangePasswordResponses = {
export type ApiUserChangePasswordResponse = ApiUserChangePasswordResponses[keyof ApiUserChangePasswordResponses];
export type ApiUserRequestPasswordResetData = {
body: ResetPasswordParams;
path?: never;
query?: never;
url: '/api/auth/password/reset';
};
export type ApiUserRequestPasswordResetErrors = {
/**
* Unauthorized
*/
401: ApiResponseApiError;
/**
* User not found
*/
404: ApiResponseApiError;
/**
* Internal server error
*/
500: ApiResponseApiError;
};
export type ApiUserRequestPasswordResetError = ApiUserRequestPasswordResetErrors[keyof ApiUserRequestPasswordResetErrors];
export type ApiUserRequestPasswordResetResponses = {
/**
* Password reset email sent
*/
200: ApiResponseString;
};
export type ApiUserRequestPasswordResetResponse = ApiUserRequestPasswordResetResponses[keyof ApiUserRequestPasswordResetResponses];
export type ApiUserConfirmPasswordResetData = {
body: ConfirmResetPasswordParams;
path?: never;
@ -6365,6 +6430,39 @@ export type ApiUserConfirmPasswordResetResponses = {
export type ApiUserConfirmPasswordResetResponse = ApiUserConfirmPasswordResetResponses[keyof ApiUserConfirmPasswordResetResponses];
export type ApiUserRequestPasswordResetData = {
body: ResetPasswordParams;
path?: never;
query?: never;
url: '/api/auth/password/reset';
};
export type ApiUserRequestPasswordResetErrors = {
/**
* Unauthorized
*/
401: ApiResponseApiError;
/**
* User not found
*/
404: ApiResponseApiError;
/**
* Internal server error
*/
500: ApiResponseApiError;
};
export type ApiUserRequestPasswordResetError = ApiUserRequestPasswordResetErrors[keyof ApiUserRequestPasswordResetErrors];
export type ApiUserRequestPasswordResetResponses = {
/**
* Password reset email sent
*/
200: ApiResponseString;
};
export type ApiUserRequestPasswordResetResponse = ApiUserRequestPasswordResetResponses[keyof ApiUserRequestPasswordResetResponses];
export type ApiAuthRegisterData = {
body: RegisterParams;
path?: never;
@ -14803,7 +14901,7 @@ export type GitReadmeData = {
};
query?: {
/**
* Git reference (branch, tag, commit). Defaults to HEAD.
* Git reference (branch, tag, commit). Defaults to the repository's default branch.
*/
ref?: string;
};
@ -17961,6 +18059,38 @@ export type GetProfileByUsernameResponses = {
export type GetProfileByUsernameResponse = GetProfileByUsernameResponses[keyof GetProfileByUsernameResponses];
export type GetUserActivityData = {
body?: never;
path: {
username: string;
};
query?: {
page?: number;
per_page?: number;
};
url: '/api/users/{username}/activity';
};
export type GetUserActivityErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Not found
*/
404: unknown;
};
export type GetUserActivityResponses = {
/**
* Get user activity
*/
200: ApiResponseUserActivityResponse;
};
export type GetUserActivityResponse = GetUserActivityResponses[keyof GetUserActivityResponses];
export type UnsubscribeTargetData = {
body?: never;
path: {
@ -18094,6 +18224,35 @@ export type GetSubscriberCountResponses = {
200: unknown;
};
export type GetFollowingListData = {
body?: never;
path: {
username: string;
};
query?: never;
url: '/api/users/{username}/following';
};
export type GetFollowingListErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Not found
*/
404: unknown;
};
export type GetFollowingListResponses = {
/**
* List following users
*/
200: ApiResponseVecUserCard;
};
export type GetFollowingListResponse = GetFollowingListResponses[keyof GetFollowingListResponses];
export type GetSubscriptionCountData = {
body?: never;
path: {
@ -18237,6 +18396,35 @@ export type GetUserReposResponses = {
export type GetUserReposResponse = GetUserReposResponses[keyof GetUserReposResponses];
export type GetUserStarsData = {
body?: never;
path: {
username: string;
};
query?: never;
url: '/api/users/{username}/stars';
};
export type GetUserStarsErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Not found
*/
404: unknown;
};
export type GetUserStarsResponses = {
/**
* Get user stars
*/
200: ApiResponseUserStarsResponse;
};
export type GetUserStarsResponse = GetUserStarsResponses[keyof GetUserStarsResponses];
export type WorkspaceCreateData = {
body: WorkspaceInitParams;
path?: never;
@ -18295,35 +18483,6 @@ export type WorkspaceAcceptInvitationResponses = {
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
export type WorkspaceMyInvitationsData = {
body?: never;
path?: never;
query?: never;
url: '/api/workspaces/me/invitations';
};
export type WorkspaceMyInvitationsErrors = {
/**
* Unauthorized
*/
401: unknown;
};
export type ApiResponseVecMyWorkspaceInvitation = {
code: number;
message: string;
data?: Array<MyWorkspaceInvitation>;
};
export type WorkspaceMyInvitationsResponses = {
/**
* List my workspace invitations
*/
200: ApiResponseVecMyWorkspaceInvitation;
};
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
export type WorkspaceAcceptInvitationBySlugData = {
body: WorkspaceAcceptBySlugParams;
path?: never;
@ -18332,6 +18491,10 @@ export type WorkspaceAcceptInvitationBySlugData = {
};
export type WorkspaceAcceptInvitationBySlugErrors = {
/**
* Invalid or expired token
*/
400: unknown;
/**
* Unauthorized
*/
@ -18378,6 +18541,29 @@ export type WorkspaceListResponses = {
export type WorkspaceListResponse2 = WorkspaceListResponses[keyof WorkspaceListResponses];
export type WorkspaceMyInvitationsData = {
body?: never;
path?: never;
query?: never;
url: '/api/workspaces/me/invitations';
};
export type WorkspaceMyInvitationsErrors = {
/**
* Unauthorized
*/
401: unknown;
};
export type WorkspaceMyInvitationsResponses = {
/**
* List my workspace invitations
*/
200: ApiResponseVecMyWorkspaceInvitation;
};
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
export type WorkspaceDeleteData = {
body?: never;
path: {