Compare commits
3 Commits
f7a3501262
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c86bc14a | |||
| db6d6de74a | |||
| d5f7e06e4e |
Generated
+1192
-54
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"vitepress": "^1.6.4",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+179
-3
@@ -1,9 +1,10 @@
|
||||
import { db } from "./db.mjs";
|
||||
import { json, readBody } from "./http.mjs";
|
||||
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
|
||||
import { verifyPassword } from "./security.mjs";
|
||||
import { hashPassword, verifyPassword } from "./security.mjs";
|
||||
import { hasPermission, publicPermissions } from "./permissions.mjs";
|
||||
|
||||
function publicUser(user) {
|
||||
export function publicUser(user) {
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
@@ -12,6 +13,12 @@ function publicUser(user) {
|
||||
name: user.name,
|
||||
avatarUrl: user.avatar_url,
|
||||
role: user.role,
|
||||
status: user.status || "active",
|
||||
emailVerified: Boolean(user.email_verified),
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
lastLoginAt: user.last_login_at,
|
||||
permissions: publicPermissions(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,12 +40,49 @@ export function requireCmsUser(request, response) {
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export function requirePermission(request, response, permission) {
|
||||
const user = requireCmsUser(request, response);
|
||||
|
||||
if (!user) return undefined;
|
||||
|
||||
if (!hasPermission(user, permission)) {
|
||||
json(response, 403, { error: "Permission denied" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function settingEnabled(key, fallback = false) {
|
||||
const row = db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key);
|
||||
return (row?.value ?? String(fallback)) === "true";
|
||||
}
|
||||
|
||||
function validateEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
async function handleMe(request, response) {
|
||||
const { session } = getRequestSession(request);
|
||||
json(response, 200, { user: session?.user ?? null });
|
||||
}
|
||||
|
||||
async function handleOptions(request, response) {
|
||||
json(response, 200, {
|
||||
options: {
|
||||
allowEmailLogin: settingEnabled("auth.allow_email_login", true),
|
||||
allowGitHubLogin: settingEnabled("auth.allow_github_login", true),
|
||||
allowRegistration: settingEnabled("auth.allow_registration", false),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLogin(request, response) {
|
||||
if (!settingEnabled("auth.allow_email_login", true)) {
|
||||
json(response, 403, { error: "Email login is disabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse(await readBody(request) || "{}");
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const password = String(body.password || "");
|
||||
@@ -55,7 +99,15 @@ async function handleLogin(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionUser = publicUser(user);
|
||||
if ((user.status || "active") !== "active") {
|
||||
json(response, 403, { error: "This account is disabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(user.id);
|
||||
const nextUser = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
|
||||
|
||||
const sessionUser = publicUser(nextUser);
|
||||
const sessionId = createSession({
|
||||
user: sessionUser,
|
||||
});
|
||||
@@ -63,12 +115,116 @@ async function handleLogin(request, response) {
|
||||
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) });
|
||||
}
|
||||
|
||||
async function handleRegister(request, response) {
|
||||
if (!settingEnabled("auth.allow_registration", false)) {
|
||||
json(response, 403, { error: "Registration is disabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const name = String(body.name || "").trim();
|
||||
const password = String(body.password || "");
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
json(response, 400, { error: "A valid email is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
json(response, 400, { error: "Name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
json(response, 400, { error: "Password must be at least 8 characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) {
|
||||
json(response, 409, { error: "Email already exists" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, 'user', 0)")
|
||||
.run(email, hashPassword(password), name);
|
||||
|
||||
const user = publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid));
|
||||
const sessionId = createSession({ user });
|
||||
|
||||
json(response, 201, { user }, { "Set-Cookie": sessionCookie(sessionId) });
|
||||
}
|
||||
|
||||
async function handleLogout(request, response) {
|
||||
const { sessionId } = getRequestSession(request);
|
||||
deleteSession(sessionId);
|
||||
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
|
||||
}
|
||||
|
||||
async function handleUpdateProfile(request, response) {
|
||||
const { sessionId, session } = getRequestSession(request);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
json(response, 401, { error: "Not signed in" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const name = String(body.name || "").trim();
|
||||
|
||||
if (!name) {
|
||||
json(response, 400, { error: "Name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(name, session.user.id);
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id);
|
||||
const sessionUser = publicUser(user);
|
||||
|
||||
delete session.user;
|
||||
const nextSession = {
|
||||
...session,
|
||||
user: sessionUser,
|
||||
};
|
||||
deleteSession(sessionId);
|
||||
const nextSessionId = createSession(nextSession);
|
||||
|
||||
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(nextSessionId) });
|
||||
}
|
||||
|
||||
async function handleChangePassword(request, response) {
|
||||
const { session } = getRequestSession(request);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
json(response, 401, { error: "Not signed in" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const currentPassword = String(body.currentPassword || "");
|
||||
const newPassword = String(body.newPassword || "");
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
json(response, 400, { error: "New password must be at least 8 characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.password_hash && !verifyPassword(currentPassword, user.password_hash)) {
|
||||
json(response, 403, { error: "Current password is incorrect" });
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
|
||||
hashPassword(newPassword),
|
||||
session.user.id,
|
||||
);
|
||||
|
||||
json(response, 200, { ok: true });
|
||||
}
|
||||
|
||||
export async function handleAuthApi(request, response, url) {
|
||||
try {
|
||||
if (url.pathname === "/api/auth/me" && request.method === "GET") {
|
||||
@@ -76,16 +232,36 @@ export async function handleAuthApi(request, response, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/auth/options" && request.method === "GET") {
|
||||
await handleOptions(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/auth/login" && request.method === "POST") {
|
||||
await handleLogin(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/auth/register" && request.method === "POST") {
|
||||
await handleRegister(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
|
||||
await handleLogout(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/auth/profile" && request.method === "PATCH") {
|
||||
await handleUpdateProfile(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/auth/password" && request.method === "POST") {
|
||||
await handleChangePassword(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
json(response, 500, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, getSetting, migrate, setSetting } from "./db.mjs";
|
||||
import { db, migrate, setSetting } from "./db.mjs";
|
||||
import { hashPassword } from "./security.mjs";
|
||||
|
||||
const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com";
|
||||
@@ -33,7 +33,9 @@ function seedSettings() {
|
||||
};
|
||||
|
||||
Object.entries(seeds).forEach(([key, value]) => {
|
||||
if (getSetting(key, undefined) === undefined) {
|
||||
const existing = db.prepare("SELECT key FROM system_settings WHERE key = ?").get(key);
|
||||
|
||||
if (!existing) {
|
||||
setSetting(key, value, key.includes("secret") || key.includes("password"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,6 +67,16 @@ export function migrate() {
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all().map((column) => column.name);
|
||||
|
||||
if (!userColumns.includes("status")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
|
||||
}
|
||||
|
||||
if (!userColumns.includes("last_login_at")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN last_login_at TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
export function getSetting(key, fallback = "") {
|
||||
|
||||
+583
-21
@@ -1,10 +1,202 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { db } from "./db.mjs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { db, getSetting } from "./db.mjs";
|
||||
import { json, readBody, redirect } from "./http.mjs";
|
||||
import { getRequestSession } from "./auth.mjs";
|
||||
import { createSession, sessionCookie, updateSession } from "./session.mjs";
|
||||
|
||||
const oauthStates = new Map();
|
||||
const githubRequestTimeoutMs = Number(process.env.GITHUB_HTTP_TIMEOUT_MS || 60000);
|
||||
const githubUserAgent = "VitePress-CMS";
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetriableNetworkError(error) {
|
||||
return ["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "ECONNREFUSED"].includes(error?.code);
|
||||
}
|
||||
|
||||
function canUsePowerShellFallback(targetUrl) {
|
||||
const url = new URL(targetUrl);
|
||||
return process.platform === "win32" && url.hostname === "github.com";
|
||||
}
|
||||
|
||||
function requestTextWithPowerShell(targetUrl, init = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
url: targetUrl,
|
||||
method: init.method || "GET",
|
||||
headers: init.headers || {},
|
||||
body: init.body || "",
|
||||
}),
|
||||
"utf-8",
|
||||
).toString("base64");
|
||||
const script = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:VITEPRESS_CMS_PS_PAYLOAD)) | ConvertFrom-Json
|
||||
$headers = @{}
|
||||
if ($payload.headers) {
|
||||
foreach ($property in $payload.headers.PSObject.Properties) {
|
||||
$headers[$property.Name] = [string]$property.Value
|
||||
}
|
||||
}
|
||||
$params = @{
|
||||
Uri = [string]$payload.url
|
||||
Method = [string]$payload.method
|
||||
Headers = $headers
|
||||
TimeoutSec = 180
|
||||
UseBasicParsing = $true
|
||||
}
|
||||
if ($payload.body -ne $null -and [string]$payload.body -ne '') {
|
||||
$params.Body = [string]$payload.body
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest @params
|
||||
$result = @{
|
||||
ok = ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
|
||||
status = [int]$response.StatusCode
|
||||
headers = @{}
|
||||
text = [string]$response.Content
|
||||
}
|
||||
} catch {
|
||||
$webResponse = $_.Exception.Response
|
||||
if ($webResponse -eq $null) {
|
||||
throw
|
||||
}
|
||||
$stream = $webResponse.GetResponseStream()
|
||||
$reader = New-Object IO.StreamReader($stream)
|
||||
$text = $reader.ReadToEnd()
|
||||
$status = [int]$webResponse.StatusCode
|
||||
$result = @{
|
||||
ok = ($status -ge 200 -and $status -lt 300)
|
||||
status = $status
|
||||
headers = @{}
|
||||
text = [string]$text
|
||||
}
|
||||
}
|
||||
$result | ConvertTo-Json -Compress -Depth 5
|
||||
`;
|
||||
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
|
||||
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand], {
|
||||
env: {
|
||||
...process.env,
|
||||
VITEPRESS_CMS_PS_PAYLOAD: payload,
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error("PowerShell GitHub request timed out"));
|
||||
}, 190000);
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new Error(Buffer.concat(stderr).toString("utf-8") || `PowerShell exited with ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(stdout).toString("utf-8")));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requestText(targetUrl, init = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(targetUrl);
|
||||
const transport = url.protocol === "http:" ? httpRequest : httpsRequest;
|
||||
const headers = { ...init.headers };
|
||||
const body = init.body;
|
||||
|
||||
if (typeof body === "string" && !headers["Content-Length"]) {
|
||||
headers["Content-Length"] = Buffer.byteLength(body);
|
||||
}
|
||||
|
||||
const request = transport(
|
||||
url,
|
||||
{
|
||||
method: init.method || "GET",
|
||||
headers,
|
||||
timeout: githubRequestTimeoutMs,
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
|
||||
response.on("data", (chunk) => chunks.push(chunk));
|
||||
response.on("end", () => {
|
||||
resolve({
|
||||
ok: response.statusCode >= 200 && response.statusCode < 300,
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
text: Buffer.concat(chunks).toString("utf-8"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error(`GitHub request timed out after ${githubRequestTimeoutMs}ms`));
|
||||
});
|
||||
request.on("error", reject);
|
||||
|
||||
if (body) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson(targetUrl, init = {}) {
|
||||
let response;
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
response = await requestText(targetUrl, init);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (canUsePowerShellFallback(targetUrl) && isRetriableNetworkError(error)) {
|
||||
response = await requestTextWithPowerShell(targetUrl, init);
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt === 3 || !isRetriableNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sleep(1000 * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
|
||||
if (response?.text) {
|
||||
try {
|
||||
payload = JSON.parse(response.text);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...response, payload };
|
||||
}
|
||||
|
||||
function getBaseUrl(request) {
|
||||
const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL;
|
||||
@@ -15,39 +207,64 @@ function getBaseUrl(request) {
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return value.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function getSettingOrFallback(key, fallback = "") {
|
||||
const value = getSetting(key, "");
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function getGithubConfig() {
|
||||
return {
|
||||
apiBaseUrl: trimTrailingSlash(getSettingOrFallback("github.api_base_url", process.env.GITHUB_API_BASE_URL || "https://api.github.com")),
|
||||
webBaseUrl: trimTrailingSlash(getSettingOrFallback("github.web_base_url", process.env.GITHUB_WEB_BASE_URL || "https://github.com")),
|
||||
oauthEnabled: getSettingOrFallback("auth.allow_github_login", "true") === "true",
|
||||
clientId: getSettingOrFallback("github.oauth_client_id", process.env.GITHUB_CLIENT_ID || ""),
|
||||
clientSecret: getSettingOrFallback("github.oauth_client_secret", process.env.GITHUB_CLIENT_SECRET || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function getOAuthConfig(request) {
|
||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
||||
const config = getGithubConfig();
|
||||
const baseUrl = getBaseUrl(request);
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
if (!config.oauthEnabled) {
|
||||
throw new Error("GitHub login is disabled in system settings");
|
||||
}
|
||||
|
||||
if (!config.clientId || !config.clientSecret) {
|
||||
throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET");
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
...config,
|
||||
redirectUri: `${baseUrl}/api/github/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
async function githubFetch(session, path, init = {}) {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
const { apiBaseUrl } = getGithubConfig();
|
||||
const response = await requestJson(`${apiBaseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
"User-Agent": githubUserAgent,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
throw new Error(`GitHub API error ${response.status}: ${details}`);
|
||||
const details = response.text;
|
||||
const error = new Error(`GitHub API error ${response.status}: ${details}`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.payload;
|
||||
}
|
||||
|
||||
function requireSession(request, response) {
|
||||
@@ -71,6 +288,11 @@ function publicCmsUser(user) {
|
||||
name: user.name,
|
||||
avatarUrl: user.avatar_url,
|
||||
role: user.role,
|
||||
status: user.status || "active",
|
||||
emailVerified: Boolean(user.email_verified),
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
lastLoginAt: user.last_login_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,18 +342,18 @@ function findOrCreateGithubUser(githubUser, accessToken, currentSession) {
|
||||
}
|
||||
|
||||
async function handleLogin(request, response) {
|
||||
const { clientId, redirectUri } = getOAuthConfig(request);
|
||||
const { clientId, redirectUri, webBaseUrl } = getOAuthConfig(request);
|
||||
const state = randomBytes(16).toString("hex");
|
||||
oauthStates.set(state, Date.now());
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: "repo",
|
||||
scope: "repo workflow",
|
||||
state,
|
||||
});
|
||||
|
||||
redirect(response, `https://github.com/login/oauth/authorize?${params.toString()}`);
|
||||
redirect(response, `${webBaseUrl}/login/oauth/authorize?${params.toString()}`);
|
||||
}
|
||||
|
||||
async function handleCallback(request, response, url) {
|
||||
@@ -144,12 +366,13 @@ async function handleCallback(request, response, url) {
|
||||
}
|
||||
|
||||
oauthStates.delete(state);
|
||||
const { clientId, clientSecret, redirectUri } = getOAuthConfig(request);
|
||||
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
||||
const { clientId, clientSecret, redirectUri, apiBaseUrl, webBaseUrl } = getOAuthConfig(request);
|
||||
const tokenResponse = await requestJson(`${webBaseUrl}/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": githubUserAgent,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
@@ -158,25 +381,37 @@ async function handleCallback(request, response, url) {
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
const tokenPayload = await tokenResponse.json();
|
||||
const tokenPayload = tokenResponse.payload ?? {};
|
||||
|
||||
if (!tokenPayload.access_token) {
|
||||
redirect(response, "/?github=token_error");
|
||||
return;
|
||||
}
|
||||
|
||||
const userResponse = await fetch("https://api.github.com/user", {
|
||||
const userResponse = await requestJson(`${apiBaseUrl}/user`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${tokenPayload.access_token}`,
|
||||
"User-Agent": githubUserAgent,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
const user = await userResponse.json();
|
||||
|
||||
if (!userResponse.ok) {
|
||||
throw new Error(`GitHub user API error ${userResponse.status}: ${userResponse.text}`);
|
||||
}
|
||||
|
||||
const user = userResponse.payload ?? {};
|
||||
const { session: currentSession } = getRequestSession(request);
|
||||
const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession);
|
||||
if ((cmsUser.status || "active") !== "active") {
|
||||
throw new Error("This account is disabled");
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(cmsUser.id);
|
||||
const nextCmsUser = db.prepare("SELECT * FROM users WHERE id = ?").get(cmsUser.id);
|
||||
const sessionId = createSession({
|
||||
user: publicCmsUser(cmsUser),
|
||||
user: publicCmsUser(nextCmsUser),
|
||||
github: {
|
||||
accessToken: tokenPayload.access_token,
|
||||
login: user.login,
|
||||
@@ -272,6 +507,10 @@ function normalizeSiteRoot(siteRoot = "") {
|
||||
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function getVitePressSiteRoot(siteRoot = "") {
|
||||
return normalizeSiteRoot(siteRoot) || "docs";
|
||||
}
|
||||
|
||||
function toRepoPath(siteRoot, path) {
|
||||
const normalizedRoot = normalizeSiteRoot(siteRoot);
|
||||
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
|
||||
@@ -290,6 +529,177 @@ function encodeBase64Content(content) {
|
||||
return Buffer.from(content, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
function packageJsonTemplate(siteRoot) {
|
||||
const root = getVitePressSiteRoot(siteRoot);
|
||||
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
type: "module",
|
||||
scripts: {
|
||||
"docs:dev": `vitepress dev ${root}`,
|
||||
"docs:build": `vitepress build ${root}`,
|
||||
"docs:preview": `vitepress preview ${root}`,
|
||||
},
|
||||
devDependencies: {
|
||||
vitepress: "^1.6.4",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
function mergePackageJsonContent(existingSource, siteRoot) {
|
||||
const root = getVitePressSiteRoot(siteRoot);
|
||||
let packageJson;
|
||||
|
||||
try {
|
||||
packageJson = JSON.parse(existingSource);
|
||||
} catch {
|
||||
throw new Error("package.json is not valid JSON");
|
||||
}
|
||||
|
||||
packageJson.type = "module";
|
||||
packageJson.scripts = {
|
||||
...packageJson.scripts,
|
||||
"docs:dev": `vitepress dev ${root}`,
|
||||
"docs:build": `vitepress build ${root}`,
|
||||
"docs:preview": `vitepress preview ${root}`,
|
||||
};
|
||||
packageJson.devDependencies = {
|
||||
...packageJson.devDependencies,
|
||||
vitepress: packageJson.devDependencies?.vitepress || "^1.6.4",
|
||||
};
|
||||
|
||||
return `${JSON.stringify(packageJson, null, 2)}\n`;
|
||||
}
|
||||
|
||||
async function loadVitePressScaffold() {
|
||||
try {
|
||||
const module = await import("vitepress");
|
||||
return module.scaffold;
|
||||
} catch {
|
||||
const module = await import("../test-vitepress/node_modules/vitepress/dist/node/index.js");
|
||||
return module.scaffold;
|
||||
}
|
||||
}
|
||||
|
||||
async function createOfficialVitePressFiles(siteRoot) {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "vitepress-cms-init-"));
|
||||
const cwd = process.cwd();
|
||||
|
||||
try {
|
||||
const scaffold = await loadVitePressScaffold();
|
||||
writeFileSync(join(tempRoot, "package.json"), packageJsonTemplate(siteRoot), "utf-8");
|
||||
process.chdir(tempRoot);
|
||||
scaffold({
|
||||
root: siteRoot,
|
||||
title: "VitePress Site",
|
||||
description: "Created by VitePress-CMS",
|
||||
theme: "default theme",
|
||||
useTs: true,
|
||||
injectNpmScripts: true,
|
||||
});
|
||||
|
||||
const configPath = [
|
||||
toRepoPath(siteRoot, ".vitepress/config.ts"),
|
||||
toRepoPath(siteRoot, ".vitepress/config.mts"),
|
||||
toRepoPath(siteRoot, ".vitepress/config.mjs"),
|
||||
toRepoPath(siteRoot, ".vitepress/config.js"),
|
||||
].find((path) => {
|
||||
try {
|
||||
readFileSync(join(tempRoot, path), "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!configPath) {
|
||||
throw new Error("VitePress official scaffold did not generate a config file");
|
||||
}
|
||||
|
||||
const filePaths = [
|
||||
"package.json",
|
||||
toRepoPath(siteRoot, "index.md"),
|
||||
toRepoPath(siteRoot, "api-examples.md"),
|
||||
toRepoPath(siteRoot, "markdown-examples.md"),
|
||||
configPath,
|
||||
];
|
||||
|
||||
return filePaths.map((path) => ({
|
||||
path,
|
||||
content:
|
||||
path === "package.json"
|
||||
? mergePackageJsonContent(readFileSync(join(tempRoot, path), "utf-8"), siteRoot)
|
||||
: readFileSync(join(tempRoot, path), "utf-8"),
|
||||
}));
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function deployWorkflowTemplate(siteRoot, branch) {
|
||||
const root = getVitePressSiteRoot(siteRoot);
|
||||
const outputDir = `${root}/.vitepress/dist`;
|
||||
|
||||
return `name: Deploy VitePress site to Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [${branch || "main"}]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm install
|
||||
- run: npm run docs:build
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ${outputDir}
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: \${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
`;
|
||||
}
|
||||
|
||||
async function initTemplateFiles(siteRoot, branch) {
|
||||
const root = getVitePressSiteRoot(siteRoot);
|
||||
const files = await createOfficialVitePressFiles(root);
|
||||
|
||||
return [
|
||||
...files,
|
||||
{
|
||||
path: ".github/workflows/deploy.yml",
|
||||
content: deployWorkflowTemplate(siteRoot, branch),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function parseFrontmatter(source) {
|
||||
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
|
||||
@@ -401,7 +811,7 @@ function getSiteQuery(url) {
|
||||
const owner = url.searchParams.get("owner");
|
||||
const repo = url.searchParams.get("repo");
|
||||
const branch = url.searchParams.get("branch") || "main";
|
||||
const siteRoot = url.searchParams.get("siteRoot") || "";
|
||||
const siteRoot = getVitePressSiteRoot(url.searchParams.get("siteRoot") || "");
|
||||
|
||||
if (!owner || !repo) {
|
||||
throw new Error("owner and repo are required");
|
||||
@@ -419,6 +829,153 @@ async function getRepoContent(session, site, path) {
|
||||
);
|
||||
}
|
||||
|
||||
async function getRepoPathContent(session, site, repoPath) {
|
||||
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||
return githubFetch(
|
||||
session,
|
||||
`/repos/${site.owner}/${site.repo}/contents/${encodedPath}?ref=${encodeURIComponent(site.branch)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function createRepoPathFile(session, site, file) {
|
||||
try {
|
||||
const existing = await getRepoPathContent(session, site, file.path);
|
||||
|
||||
if (file.path === "package.json") {
|
||||
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
|
||||
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: "docs: update package metadata from VitePress-CMS",
|
||||
content: encodeBase64Content(mergePackageJsonContent(decodeBase64Content(existing.content), site.siteRoot)),
|
||||
branch: site.branch,
|
||||
sha: existing.sha,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
status: "updated",
|
||||
sha: result.content.sha,
|
||||
};
|
||||
}
|
||||
|
||||
if (file.path.startsWith(".github/workflows/")) {
|
||||
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
|
||||
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: `docs: update ${file.path} from VitePress-CMS`,
|
||||
content: encodeBase64Content(file.content),
|
||||
branch: site.branch,
|
||||
sha: existing.sha,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
status: "updated",
|
||||
sha: result.content.sha,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
status: "skipped",
|
||||
};
|
||||
} catch (error) {
|
||||
if (file.path.startsWith(".github/workflows/") && error?.statusCode === 403) {
|
||||
return {
|
||||
path: file.path,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (error?.statusCode !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: `docs: initialize ${file.path} from VitePress-CMS`,
|
||||
content: encodeBase64Content(file.content),
|
||||
branch: site.branch,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (file.path.startsWith(".github/workflows/") && [403, 404].includes(error?.statusCode)) {
|
||||
return {
|
||||
path: file.path,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRetriableNetworkError(error) || error?.statusCode === 422) {
|
||||
const existing = await getRepoPathContent(session, site, file.path).catch(() => null);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
path: file.path,
|
||||
status: "skipped",
|
||||
sha: existing.sha,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
status: "created",
|
||||
sha: result.content.sha,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleInitSite(request, response) {
|
||||
const session = requireSession(request, response);
|
||||
if (!session) return;
|
||||
|
||||
const body = JSON.parse(await readBody(request) || "{}");
|
||||
const site = body.site;
|
||||
|
||||
if (!site?.owner || !site?.repo) {
|
||||
json(response, 400, { error: "owner and repo are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSite = {
|
||||
owner: String(site.owner),
|
||||
repo: String(site.repo),
|
||||
branch: String(site.branch || "main"),
|
||||
siteRoot: getVitePressSiteRoot(String(site.siteRoot || "")),
|
||||
};
|
||||
const files = [];
|
||||
|
||||
for (const file of await initTemplateFiles(normalizedSite.siteRoot, normalizedSite.branch)) {
|
||||
files.push(await createRepoPathFile(session, normalizedSite, file));
|
||||
}
|
||||
|
||||
json(response, 200, { files });
|
||||
}
|
||||
|
||||
async function handleSitePages(request, response, url) {
|
||||
const session = requireSession(request, response);
|
||||
if (!session) return;
|
||||
@@ -557,6 +1114,11 @@ export async function handleGithubApi(request, response, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/github/site/init" && request.method === "POST") {
|
||||
await handleInitSite(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/github/site/pages") {
|
||||
await handleSitePages(request, response, url);
|
||||
return true;
|
||||
@@ -569,7 +1131,7 @@ export async function handleGithubApi(request, response, url) {
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
json(response, 500, {
|
||||
json(response, error?.statusCode || 500, {
|
||||
error: error instanceof Error ? error.message : "Unknown GitHub API error",
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { bootstrap } from "./bootstrap.mjs";
|
||||
import { handleAuthApi } from "./auth.mjs";
|
||||
import { handleGithubApi } from "./github.mjs";
|
||||
import { handleSettingsApi } from "./settings.mjs";
|
||||
import { handleUsersApi } from "./users.mjs";
|
||||
import { handleProjectsApi } from "./projects.mjs";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const port = Number(process.env.PORT || 5173);
|
||||
@@ -64,6 +66,14 @@ const server = createServer(async (request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleUsersApi(request, response, url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleProjectsApi(request, response, url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleGithubApi(request, response, url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const permissions = {
|
||||
settingsManage: "settings.manage",
|
||||
usersManage: "users.manage",
|
||||
projectsManage: "projects.manage",
|
||||
contentEdit: "content.edit",
|
||||
contentPublish: "content.publish",
|
||||
contentView: "content.view",
|
||||
};
|
||||
|
||||
const rolePermissions = {
|
||||
system_admin: Object.values(permissions),
|
||||
admin: [
|
||||
permissions.usersManage,
|
||||
permissions.projectsManage,
|
||||
permissions.contentEdit,
|
||||
permissions.contentPublish,
|
||||
permissions.contentView,
|
||||
],
|
||||
editor: [permissions.contentEdit, permissions.contentPublish, permissions.contentView],
|
||||
viewer: [permissions.contentView],
|
||||
user: [permissions.contentView],
|
||||
};
|
||||
|
||||
export function hasPermission(user, permission) {
|
||||
if (!user) return false;
|
||||
return Boolean(rolePermissions[user.role]?.includes(permission));
|
||||
}
|
||||
|
||||
export function publicPermissions(user) {
|
||||
return rolePermissions[user?.role] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { db } from "./db.mjs";
|
||||
import { requireCmsUser } from "./auth.mjs";
|
||||
import { json, readBody } from "./http.mjs";
|
||||
|
||||
const projectRoles = ["owner", "admin", "editor", "viewer"];
|
||||
const projectManagers = ["owner", "admin"];
|
||||
|
||||
function publicProject(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
githubOwner: row.github_owner,
|
||||
githubRepo: row.github_repo,
|
||||
githubBranch: row.github_branch,
|
||||
siteRoot: row.site_root,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
ownerUserId: row.owner_user_id,
|
||||
};
|
||||
}
|
||||
|
||||
function publicMember(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
userId: row.user_id,
|
||||
role: row.role,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatar_url,
|
||||
status: row.status || "active",
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function getProject(projectId) {
|
||||
return db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
||||
}
|
||||
|
||||
function hasGlobalProjectAccess(user) {
|
||||
return user.role === "system_admin" || user.role === "admin";
|
||||
}
|
||||
|
||||
function getProjectMember(projectId, userId) {
|
||||
return db.prepare("SELECT * FROM project_members WHERE project_id = ? AND user_id = ?").get(projectId, userId);
|
||||
}
|
||||
|
||||
function canViewProject(user, projectId) {
|
||||
return hasGlobalProjectAccess(user) || Boolean(getProjectMember(projectId, user.id));
|
||||
}
|
||||
|
||||
function canManageProject(user, projectId) {
|
||||
if (hasGlobalProjectAccess(user)) return true;
|
||||
const member = getProjectMember(projectId, user.id);
|
||||
return Boolean(member && projectManagers.includes(member.role));
|
||||
}
|
||||
|
||||
function requireProjectView(request, response, projectId) {
|
||||
const user = requireCmsUser(request, response);
|
||||
if (!user) return undefined;
|
||||
|
||||
if (!canViewProject(user, projectId)) {
|
||||
json(response, 403, { error: "Permission denied" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function requireProjectManage(request, response, projectId) {
|
||||
const user = requireCmsUser(request, response);
|
||||
if (!user) return undefined;
|
||||
|
||||
if (!canManageProject(user, projectId)) {
|
||||
json(response, 403, { error: "Only project owners and admins can manage this project" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function listMembers(projectId) {
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT project_members.*, users.name, users.email, users.avatar_url, users.status
|
||||
FROM project_members
|
||||
INNER JOIN users ON users.id = project_members.user_id
|
||||
WHERE project_members.project_id = ?
|
||||
ORDER BY
|
||||
CASE project_members.role
|
||||
WHEN 'owner' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'editor' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
project_members.created_at ASC
|
||||
`)
|
||||
.all(projectId)
|
||||
.map(publicMember);
|
||||
}
|
||||
|
||||
function listProjects(user) {
|
||||
if (user.role === "system_admin" || user.role === "admin") {
|
||||
return db.prepare("SELECT * FROM projects ORDER BY updated_at DESC, id DESC").all().map(publicProject);
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT projects.*
|
||||
FROM projects
|
||||
INNER JOIN project_members ON project_members.project_id = projects.id
|
||||
WHERE project_members.user_id = ?
|
||||
ORDER BY projects.updated_at DESC, projects.id DESC
|
||||
`)
|
||||
.all(user.id)
|
||||
.map(publicProject);
|
||||
}
|
||||
|
||||
async function handleListProjects(request, response) {
|
||||
const user = requireCmsUser(request, response);
|
||||
if (!user) return;
|
||||
|
||||
json(response, 200, { projects: listProjects(user) });
|
||||
}
|
||||
|
||||
async function handleCreateProject(request, response) {
|
||||
const user = requireCmsUser(request, response);
|
||||
if (!user) return;
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const name = String(body.name || "").trim();
|
||||
|
||||
if (!name) {
|
||||
json(response, 400, { error: "Project name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare(`
|
||||
INSERT INTO projects (owner_user_id, name, github_owner, github_repo, github_branch, site_root)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(
|
||||
user.id,
|
||||
name,
|
||||
String(body.githubOwner || "").trim() || null,
|
||||
String(body.githubRepo || "").trim() || null,
|
||||
String(body.githubBranch || "main").trim() || "main",
|
||||
String(body.siteRoot || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs",
|
||||
);
|
||||
|
||||
db.prepare("INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')").run(
|
||||
result.lastInsertRowid,
|
||||
user.id,
|
||||
);
|
||||
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(result.lastInsertRowid);
|
||||
json(response, 201, { project: publicProject(project) });
|
||||
}
|
||||
|
||||
async function handleUpdateProject(request, response, projectId) {
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
||||
if (!project) {
|
||||
json(response, 404, { error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = requireProjectManage(request, response, projectId);
|
||||
if (!user) return;
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET name = ?,
|
||||
github_owner = ?,
|
||||
github_repo = ?,
|
||||
github_branch = ?,
|
||||
site_root = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
String(body.name || project.name).trim(),
|
||||
String(body.githubOwner ?? project.github_owner ?? "").trim() || null,
|
||||
String(body.githubRepo ?? project.github_repo ?? "").trim() || null,
|
||||
String(body.githubBranch || project.github_branch || "main").trim() || "main",
|
||||
String(body.siteRoot || project.site_root || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs",
|
||||
projectId,
|
||||
);
|
||||
|
||||
json(response, 200, { project: publicProject(db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId)) });
|
||||
}
|
||||
|
||||
async function handleListMembers(request, response, projectId) {
|
||||
if (!getProject(projectId)) {
|
||||
json(response, 404, { error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = requireProjectView(request, response, projectId);
|
||||
if (!user) return;
|
||||
|
||||
json(response, 200, { members: listMembers(projectId) });
|
||||
}
|
||||
|
||||
async function handleAddMember(request, response, projectId) {
|
||||
if (!getProject(projectId)) {
|
||||
json(response, 404, { error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = requireProjectManage(request, response, projectId);
|
||||
if (!user) return;
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const role = String(body.role || "editor");
|
||||
|
||||
if (!projectRoles.includes(role)) {
|
||||
json(response, 400, { error: "Invalid member role" });
|
||||
return;
|
||||
}
|
||||
|
||||
const target = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
|
||||
if (!target) {
|
||||
json(response, 404, { error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO project_members (project_id, user_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(project_id, user_id) DO UPDATE SET role = excluded.role
|
||||
`).run(projectId, target.id, role);
|
||||
|
||||
json(response, 200, { members: listMembers(projectId) });
|
||||
}
|
||||
|
||||
async function handleUpdateMember(request, response, projectId, memberId) {
|
||||
const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId);
|
||||
if (!member) {
|
||||
json(response, 404, { error: "Member not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = requireProjectManage(request, response, projectId);
|
||||
if (!user) return;
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const role = String(body.role || member.role);
|
||||
|
||||
if (!projectRoles.includes(role)) {
|
||||
json(response, 400, { error: "Invalid member role" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.role === "owner" && role !== "owner") {
|
||||
const ownerCount = db
|
||||
.prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'")
|
||||
.get(projectId).count;
|
||||
|
||||
if (ownerCount <= 1) {
|
||||
json(response, 400, { error: "At least one project owner is required" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("UPDATE project_members SET role = ? WHERE id = ?").run(role, memberId);
|
||||
json(response, 200, { members: listMembers(projectId) });
|
||||
}
|
||||
|
||||
async function handleRemoveMember(request, response, projectId, memberId) {
|
||||
const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId);
|
||||
if (!member) {
|
||||
json(response, 404, { error: "Member not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = requireProjectManage(request, response, projectId);
|
||||
if (!user) return;
|
||||
|
||||
if (member.role === "owner") {
|
||||
const ownerCount = db
|
||||
.prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'")
|
||||
.get(projectId).count;
|
||||
|
||||
if (ownerCount <= 1) {
|
||||
json(response, 400, { error: "At least one project owner is required" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("DELETE FROM project_members WHERE id = ?").run(memberId);
|
||||
json(response, 200, { members: listMembers(projectId) });
|
||||
}
|
||||
|
||||
export async function handleProjectsApi(request, response, url) {
|
||||
try {
|
||||
if (url.pathname === "/api/projects" && request.method === "GET") {
|
||||
await handleListProjects(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/projects" && request.method === "POST") {
|
||||
await handleCreateProject(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/^\/api\/projects\/(\d+)$/);
|
||||
if (match && request.method === "PATCH") {
|
||||
await handleUpdateProject(request, response, Number(match[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
const membersMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members$/);
|
||||
if (membersMatch && request.method === "GET") {
|
||||
await handleListMembers(request, response, Number(membersMatch[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (membersMatch && request.method === "POST") {
|
||||
await handleAddMember(request, response, Number(membersMatch[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
const memberMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members\/(\d+)$/);
|
||||
if (memberMatch && request.method === "PATCH") {
|
||||
await handleUpdateMember(request, response, Number(memberMatch[1]), Number(memberMatch[2]));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memberMatch && request.method === "DELETE") {
|
||||
await handleRemoveMember(request, response, Number(memberMatch[1]), Number(memberMatch[2]));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
json(response, 500, {
|
||||
error: error instanceof Error ? error.message : "Unknown projects API error",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+81
-11
@@ -1,18 +1,64 @@
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { db, setSetting } from "./db.mjs";
|
||||
import { requireCmsUser } from "./auth.mjs";
|
||||
import { requirePermission } from "./auth.mjs";
|
||||
import { json, readBody } from "./http.mjs";
|
||||
import { permissions } from "./permissions.mjs";
|
||||
|
||||
function requireAdmin(request, response) {
|
||||
const user = requireCmsUser(request, response);
|
||||
function getSettingValue(key, fallback = "") {
|
||||
return db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key)?.value ?? fallback;
|
||||
}
|
||||
|
||||
if (!user) return undefined;
|
||||
function testSmtpConnection({ host, port }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socketFactory = Number(port) === 465 ? tls.connect : net.connect;
|
||||
const socket = socketFactory({
|
||||
host,
|
||||
port: Number(port),
|
||||
servername: host,
|
||||
timeout: 10000,
|
||||
});
|
||||
let buffer = "";
|
||||
|
||||
if (user.role !== "system_admin") {
|
||||
json(response, 403, { error: "System admin permission is required" });
|
||||
return undefined;
|
||||
}
|
||||
const finish = (result) => {
|
||||
socket.removeAllListeners();
|
||||
socket.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
return user;
|
||||
socket.on("connect", () => {
|
||||
if (Number(port) === 465) {
|
||||
socket.write("EHLO vitepress-cms.local\r\n");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("secureConnect", () => {
|
||||
socket.write("EHLO vitepress-cms.local\r\n");
|
||||
});
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf-8");
|
||||
|
||||
if (buffer.startsWith("220")) {
|
||||
socket.write("EHLO vitepress-cms.local\r\n");
|
||||
}
|
||||
|
||||
if (buffer.includes("\n250 ") || buffer.includes("\n250-") || buffer.startsWith("250")) {
|
||||
socket.write("QUIT\r\n");
|
||||
finish({
|
||||
ok: true,
|
||||
greeting: buffer.split(/\r?\n/).filter(Boolean).slice(0, 3).join(" / "),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("SMTP connection timed out"));
|
||||
});
|
||||
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function publicSetting(row) {
|
||||
@@ -25,7 +71,7 @@ function publicSetting(row) {
|
||||
}
|
||||
|
||||
async function handleGetSettings(request, response) {
|
||||
if (!requireAdmin(request, response)) return;
|
||||
if (!requirePermission(request, response, permissions.settingsManage)) return;
|
||||
|
||||
const rows = db
|
||||
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key")
|
||||
@@ -35,19 +81,38 @@ async function handleGetSettings(request, response) {
|
||||
}
|
||||
|
||||
async function handleSaveSettings(request, response) {
|
||||
if (!requireAdmin(request, response)) return;
|
||||
if (!requirePermission(request, response, permissions.settingsManage)) return;
|
||||
|
||||
const body = JSON.parse(await readBody(request) || "{}");
|
||||
const settings = Array.isArray(body.settings) ? body.settings : [];
|
||||
|
||||
settings.forEach((setting) => {
|
||||
if (!setting.key) return;
|
||||
const existing = db.prepare("SELECT encrypted FROM system_settings WHERE key = ?").get(String(setting.key));
|
||||
const shouldKeepExistingSecret = Boolean(existing?.encrypted) && Boolean(setting.encrypted) && !String(setting.value ?? "");
|
||||
if (shouldKeepExistingSecret) return;
|
||||
|
||||
setSetting(String(setting.key), String(setting.value ?? ""), Boolean(setting.encrypted));
|
||||
});
|
||||
|
||||
json(response, 200, { ok: true });
|
||||
}
|
||||
|
||||
async function handleTestSmtp(request, response) {
|
||||
if (!requirePermission(request, response, permissions.settingsManage)) return;
|
||||
|
||||
const host = getSettingValue("smtp.host");
|
||||
const port = getSettingValue("smtp.port", "587");
|
||||
|
||||
if (!host) {
|
||||
json(response, 400, { error: "SMTP host is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await testSmtpConnection({ host, port });
|
||||
json(response, 200, result);
|
||||
}
|
||||
|
||||
export async function handleSettingsApi(request, response, url) {
|
||||
try {
|
||||
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
|
||||
@@ -60,6 +125,11 @@ export async function handleSettingsApi(request, response, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/admin/settings/test-smtp" && request.method === "POST") {
|
||||
await handleTestSmtp(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
json(response, 500, {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { db } from "./db.mjs";
|
||||
import { requirePermission, publicUser } from "./auth.mjs";
|
||||
import { json, readBody } from "./http.mjs";
|
||||
import { permissions } from "./permissions.mjs";
|
||||
import { hashPassword } from "./security.mjs";
|
||||
|
||||
const roles = new Set(["system_admin", "admin", "editor", "viewer", "user"]);
|
||||
const statuses = new Set(["active", "disabled"]);
|
||||
|
||||
function listUsers() {
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT id, email, name, avatar_url, role, status, email_verified, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`)
|
||||
.all()
|
||||
.map(publicUser);
|
||||
}
|
||||
|
||||
function validateEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
async function handleListUsers(request, response) {
|
||||
if (!requirePermission(request, response, permissions.usersManage)) return;
|
||||
json(response, 200, { users: listUsers() });
|
||||
}
|
||||
|
||||
async function handleCreateUser(request, response) {
|
||||
if (!requirePermission(request, response, permissions.usersManage)) return;
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const name = String(body.name || "").trim();
|
||||
const password = String(body.password || "");
|
||||
const role = String(body.role || "user");
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
json(response, 400, { error: "A valid email is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
json(response, 400, { error: "Name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
json(response, 400, { error: "Password must be at least 8 characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roles.has(role)) {
|
||||
json(response, 400, { error: "Invalid role" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) {
|
||||
json(response, 409, { error: "Email already exists" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, ?, 1)")
|
||||
.run(email, hashPassword(password), name, role);
|
||||
|
||||
json(response, 201, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid)) });
|
||||
}
|
||||
|
||||
async function handleUpdateUser(request, response, userId) {
|
||||
const actor = requirePermission(request, response, permissions.usersManage);
|
||||
if (!actor) return;
|
||||
|
||||
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
if (!target) {
|
||||
json(response, 404, { error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse((await readBody(request)) || "{}");
|
||||
const nextRole = body.role === undefined ? target.role : String(body.role);
|
||||
const nextStatus = body.status === undefined ? target.status || "active" : String(body.status);
|
||||
|
||||
if (!roles.has(nextRole)) {
|
||||
json(response, 400, { error: "Invalid role" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!statuses.has(nextStatus)) {
|
||||
json(response, 400, { error: "Invalid status" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(actor.id) === Number(userId) && nextStatus !== "active") {
|
||||
json(response, 400, { error: "You cannot disable your own account" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.role === "system_admin" && nextRole !== "system_admin") {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'system_admin'").get().count;
|
||||
if (adminCount <= 1) {
|
||||
json(response, 400, { error: "At least one system admin is required" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET role = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
|
||||
nextRole,
|
||||
nextStatus,
|
||||
userId,
|
||||
);
|
||||
|
||||
json(response, 200, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(userId)) });
|
||||
}
|
||||
|
||||
export async function handleUsersApi(request, response, url) {
|
||||
try {
|
||||
if (url.pathname === "/api/admin/users" && request.method === "GET") {
|
||||
await handleListUsers(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/admin/users" && request.method === "POST") {
|
||||
await handleCreateUser(request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/^\/api\/admin\/users\/(\d+)$/);
|
||||
if (match && request.method === "PATCH") {
|
||||
await handleUpdateUser(request, response, Number(match[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
json(response, 500, {
|
||||
error: error instanceof Error ? error.message : "Unknown users API error",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+479
-14
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { loadCurrentUser, loginWithEmail, logoutCms } from "./api/auth";
|
||||
import { changePassword, loadAuthOptions, loadCurrentUser, loginWithEmail, logoutCms, registerWithEmail, updateProfile } from "./api/auth";
|
||||
import { loadSystemSettings, saveSystemSettings, testSmtpSettings } from "./api/adminSettings";
|
||||
import {
|
||||
createGitHubRepository,
|
||||
loadGitHubRepositories,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
startGitHubLogin,
|
||||
} from "./api/githubAuth";
|
||||
import {
|
||||
initializeGitHubSite,
|
||||
loadGitHubPages,
|
||||
loadGitHubSettings,
|
||||
saveGitHubPage,
|
||||
@@ -27,18 +29,57 @@ import ConnectPanel from "./components/ConnectPanel.vue";
|
||||
import ContentPanel from "./components/ContentPanel.vue";
|
||||
import LoginView from "./components/LoginView.vue";
|
||||
import PublishPanel from "./components/PublishPanel.vue";
|
||||
import ProjectPanel from "./components/ProjectPanel.vue";
|
||||
import StructurePanel from "./components/StructurePanel.vue";
|
||||
import SystemSettingsPanel from "./components/SystemSettingsPanel.vue";
|
||||
import UserManagementPanel from "./components/UserManagementPanel.vue";
|
||||
import { createAdminUser, loadAdminUsers, updateAdminUser } from "./api/adminUsers";
|
||||
import {
|
||||
addProjectMember,
|
||||
createProject,
|
||||
loadProjectMembers,
|
||||
loadProjects,
|
||||
removeProjectMember,
|
||||
updateProject,
|
||||
updateProjectMember,
|
||||
} from "./api/projects";
|
||||
import { hasPermission, permissions } from "./utils/permissions";
|
||||
import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite";
|
||||
import type { CmsUser, DataSource, DocPage, GitHubConnection, GitHubRepository, GitHubUser, PanelKey } from "./types";
|
||||
import type {
|
||||
CmsUser,
|
||||
CmsProject,
|
||||
AuthOptions,
|
||||
DataSource,
|
||||
DocPage,
|
||||
GitHubConnection,
|
||||
GitHubRepository,
|
||||
GitHubUser,
|
||||
PanelKey,
|
||||
ProjectMember,
|
||||
SystemSetting,
|
||||
} from "./types";
|
||||
|
||||
const panels: Array<{ key: PanelKey; label: string; icon: string }> = [
|
||||
{ key: "account", label: "用户中心", icon: "U" },
|
||||
{ key: "connect", label: "仓库连接", icon: "G" },
|
||||
{ key: "content", label: "页面管理", icon: "P" },
|
||||
{ key: "structure", label: "站点结构", icon: "S" },
|
||||
{ key: "config", label: "主题配置", icon: "C" },
|
||||
{ key: "publish", label: "发布中心", icon: "R" },
|
||||
];
|
||||
const panels = computed<Array<{ key: PanelKey; label: string; icon: string }>>(() => {
|
||||
const basePanels: Array<{ key: PanelKey; label: string; icon: string }> = [
|
||||
{ key: "account", label: "用户中心", icon: "U" },
|
||||
{ key: "projects", label: "项目管理", icon: "J" },
|
||||
{ key: "connect", label: "仓库连接", icon: "G" },
|
||||
{ key: "content", label: "页面管理", icon: "P" },
|
||||
{ key: "structure", label: "站点结构", icon: "S" },
|
||||
{ key: "config", label: "主题配置", icon: "C" },
|
||||
{ key: "publish", label: "发布中心", icon: "R" },
|
||||
];
|
||||
|
||||
if (hasPermission(currentUser.value, permissions.usersManage)) {
|
||||
basePanels.push({ key: "users", label: "用户管理", icon: "M" });
|
||||
}
|
||||
|
||||
if (hasPermission(currentUser.value, permissions.settingsManage)) {
|
||||
basePanels.push({ key: "admin", label: "系统设置", icon: "A" });
|
||||
}
|
||||
|
||||
return basePanels;
|
||||
});
|
||||
|
||||
const githubConnection = reactive<GitHubConnection>({
|
||||
token: "",
|
||||
@@ -60,26 +101,59 @@ const sidebarGroups = reactive(
|
||||
})),
|
||||
);
|
||||
const siteSettings = reactive({ ...mockSiteSettings });
|
||||
const systemSettings = ref<SystemSetting[]>([]);
|
||||
const activePageId = ref("");
|
||||
const settingsSha = ref<string>();
|
||||
const isLoadingPages = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
const isLoadingRepos = ref(false);
|
||||
const isCreatingRepository = ref(false);
|
||||
const isInitializingSite = ref(false);
|
||||
const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const systemSettingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const profileSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const passwordSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const smtpTestState = ref<"idle" | "testing" | "success" | "error">("idle");
|
||||
const githubUser = ref<GitHubUser | null>(null);
|
||||
const currentUser = ref<CmsUser | null>(null);
|
||||
const authOptions = ref<AuthOptions>({
|
||||
allowEmailLogin: true,
|
||||
allowGitHubLogin: true,
|
||||
allowRegistration: false,
|
||||
});
|
||||
const adminUsers = ref<CmsUser[]>([]);
|
||||
const projects = ref<CmsProject[]>([]);
|
||||
const projectMembers = ref<ProjectMember[]>([]);
|
||||
const activeProjectId = ref<number>();
|
||||
const repositories = ref<GitHubRepository[]>([]);
|
||||
const isAuthLoading = ref(true);
|
||||
const loginError = ref("");
|
||||
const connectionMessage = ref("");
|
||||
const repositoryMessage = ref("");
|
||||
const usersMessage = ref("");
|
||||
const accountMessage = ref("");
|
||||
const smtpMessage = ref("");
|
||||
const usersLoadState = ref<"idle" | "loading" | "error">("idle");
|
||||
const projectsMessage = ref("");
|
||||
const projectsState = ref<"idle" | "loading" | "saving" | "error">("idle");
|
||||
const activityItems = ref(["等待读取站点"]);
|
||||
|
||||
const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]);
|
||||
const activePanelTitle = computed(() => panels.find((panel) => panel.key === activePanel.value)?.label ?? "");
|
||||
const activePanelTitle = computed(() => panels.value.find((panel) => panel.key === activePanel.value)?.label ?? "");
|
||||
const activeProjectMember = computed(() =>
|
||||
projectMembers.value.find((member) => member.userId === currentUser.value?.id),
|
||||
);
|
||||
const canManageActiveProject = computed(() => {
|
||||
if (!currentUser.value) return false;
|
||||
if (currentUser.value.role === "system_admin" || currentUser.value.role === "admin") return true;
|
||||
return Boolean(activeProjectMember.value && ["owner", "admin"].includes(activeProjectMember.value.role));
|
||||
});
|
||||
const repoLabel = computed(() => {
|
||||
if (dataSource.value === "github" && hasGitHubConnection.value) {
|
||||
return `${githubConnection.owner}/${githubConnection.repo}`;
|
||||
}
|
||||
|
||||
return "test-vitepress";
|
||||
});
|
||||
const repoBranch = computed(() =>
|
||||
@@ -95,7 +169,7 @@ function normalizeConnection(connection: GitHubConnection): GitHubConnection {
|
||||
owner: connection.owner.trim(),
|
||||
repo: connection.repo.trim(),
|
||||
branch: connection.branch.trim() || "main",
|
||||
siteRoot: connection.siteRoot.trim().replace(/^\/+|\/+$/g, ""),
|
||||
siteRoot: connection.siteRoot.trim().replace(/^\/+|\/+$/g, "") || "docs",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +239,203 @@ async function loadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminSettings() {
|
||||
if (!hasPermission(currentUser.value, permissions.settingsManage)) return;
|
||||
|
||||
try {
|
||||
systemSettings.value = await loadSystemSettings();
|
||||
} catch (error) {
|
||||
activityItems.value = [
|
||||
error instanceof Error ? error.message : "读取系统设置失败",
|
||||
...activityItems.value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!hasPermission(currentUser.value, permissions.usersManage)) return;
|
||||
usersLoadState.value = "loading";
|
||||
usersMessage.value = "正在读取用户列表";
|
||||
|
||||
try {
|
||||
adminUsers.value = await loadAdminUsers();
|
||||
usersMessage.value = `已读取 ${adminUsers.value.length} 个用户`;
|
||||
usersLoadState.value = "idle";
|
||||
} catch (error) {
|
||||
usersMessage.value = error instanceof Error ? error.message : "读取用户列表失败";
|
||||
usersLoadState.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
function applyProject(project: CmsProject) {
|
||||
activeProjectId.value = project.id;
|
||||
Object.assign(githubConnection, {
|
||||
token: "",
|
||||
owner: project.githubOwner || "",
|
||||
repo: project.githubRepo || "",
|
||||
branch: project.githubBranch || "main",
|
||||
siteRoot: project.siteRoot || "docs",
|
||||
});
|
||||
hasGitHubConnection.value = Boolean(project.githubOwner && project.githubRepo);
|
||||
dataSource.value = hasGitHubConnection.value ? "github" : "local";
|
||||
projectsMessage.value = `已选择项目 ${project.name}`;
|
||||
void loadMembers(project.id);
|
||||
}
|
||||
|
||||
async function loadMembers(projectId = activeProjectId.value) {
|
||||
if (!projectId) {
|
||||
projectMembers.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
projectMembers.value = await loadProjectMembers(projectId);
|
||||
} catch (error) {
|
||||
projectsMessage.value = error instanceof Error ? error.message : "读取项目成员失败";
|
||||
projectsState.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectList() {
|
||||
projectsState.value = "loading";
|
||||
projectsMessage.value = "正在读取项目列表";
|
||||
|
||||
try {
|
||||
projects.value = await loadProjects();
|
||||
if (!activeProjectId.value && projects.value[0]) {
|
||||
applyProject(projects.value[0]);
|
||||
}
|
||||
projectsMessage.value = `已读取 ${projects.value.length} 个项目`;
|
||||
projectsState.value = "idle";
|
||||
} catch (error) {
|
||||
projectsMessage.value = error instanceof Error ? error.message : "读取项目失败";
|
||||
projectsState.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async function createCmsProject(payload: {
|
||||
name: string;
|
||||
githubOwner?: string;
|
||||
githubRepo?: string;
|
||||
githubBranch?: string;
|
||||
siteRoot?: string;
|
||||
}) {
|
||||
projectsState.value = "saving";
|
||||
projectsMessage.value = "正在创建项目";
|
||||
|
||||
try {
|
||||
const project = await createProject(payload);
|
||||
projects.value = [project, ...projects.value];
|
||||
applyProject(project);
|
||||
projectsState.value = "idle";
|
||||
projectsMessage.value = `已创建项目 ${project.name}`;
|
||||
} catch (error) {
|
||||
projectsState.value = "error";
|
||||
projectsMessage.value = error instanceof Error ? error.message : "创建项目失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCmsProject(payload: {
|
||||
id: number;
|
||||
name: string;
|
||||
githubOwner?: string;
|
||||
githubRepo?: string;
|
||||
githubBranch?: string;
|
||||
siteRoot?: string;
|
||||
}) {
|
||||
projectsState.value = "saving";
|
||||
projectsMessage.value = "正在保存项目";
|
||||
|
||||
try {
|
||||
const project = await updateProject(payload.id, payload);
|
||||
projects.value = projects.value.map((item) => (item.id === project.id ? project : item));
|
||||
applyProject(project);
|
||||
projectsState.value = "idle";
|
||||
projectsMessage.value = `已保存项目 ${project.name}`;
|
||||
} catch (error) {
|
||||
projectsState.value = "error";
|
||||
projectsMessage.value = error instanceof Error ? error.message : "保存项目失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(payload: { email: string; role: string }) {
|
||||
if (!activeProjectId.value) return;
|
||||
projectsState.value = "saving";
|
||||
|
||||
try {
|
||||
projectMembers.value = await addProjectMember(activeProjectId.value, payload);
|
||||
projectsState.value = "idle";
|
||||
projectsMessage.value = "项目成员已添加";
|
||||
} catch (error) {
|
||||
projectsState.value = "error";
|
||||
projectsMessage.value = error instanceof Error ? error.message : "添加项目成员失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMember(payload: { id: number; role: string }) {
|
||||
if (!activeProjectId.value) return;
|
||||
projectsState.value = "saving";
|
||||
|
||||
try {
|
||||
projectMembers.value = await updateProjectMember(activeProjectId.value, payload.id, { role: payload.role });
|
||||
projectsState.value = "idle";
|
||||
projectsMessage.value = "项目成员已更新";
|
||||
} catch (error) {
|
||||
projectsState.value = "error";
|
||||
projectsMessage.value = error instanceof Error ? error.message : "更新项目成员失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(memberId: number) {
|
||||
if (!activeProjectId.value) return;
|
||||
projectsState.value = "saving";
|
||||
|
||||
try {
|
||||
projectMembers.value = await removeProjectMember(activeProjectId.value, memberId);
|
||||
projectsState.value = "idle";
|
||||
projectsMessage.value = "项目成员已移除";
|
||||
} catch (error) {
|
||||
projectsState.value = "error";
|
||||
projectsMessage.value = error instanceof Error ? error.message : "移除项目成员失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(payload: { id: number; role?: string; status?: string }) {
|
||||
usersLoadState.value = "loading";
|
||||
usersMessage.value = "正在更新用户";
|
||||
|
||||
try {
|
||||
const user = await updateAdminUser(payload.id, {
|
||||
role: payload.role,
|
||||
status: payload.status,
|
||||
});
|
||||
adminUsers.value = adminUsers.value.map((item) => (item.id === user.id ? user : item));
|
||||
if (currentUser.value?.id === user.id) {
|
||||
currentUser.value = user;
|
||||
}
|
||||
usersMessage.value = "用户已更新";
|
||||
usersLoadState.value = "idle";
|
||||
} catch (error) {
|
||||
usersMessage.value = error instanceof Error ? error.message : "更新用户失败";
|
||||
usersLoadState.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(payload: { email: string; name: string; password: string; role: string }) {
|
||||
usersLoadState.value = "loading";
|
||||
usersMessage.value = "正在创建用户";
|
||||
|
||||
try {
|
||||
const user = await createAdminUser(payload);
|
||||
adminUsers.value = [user, ...adminUsers.value];
|
||||
usersMessage.value = `用户 ${user.name} 已创建`;
|
||||
usersLoadState.value = "idle";
|
||||
} catch (error) {
|
||||
usersMessage.value = error instanceof Error ? error.message : "创建用户失败";
|
||||
usersLoadState.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSite() {
|
||||
await Promise.all([loadPages(), loadSettings()]);
|
||||
}
|
||||
@@ -187,6 +458,7 @@ async function refreshRepositories() {
|
||||
|
||||
async function loadAuthState() {
|
||||
try {
|
||||
authOptions.value = await loadAuthOptions();
|
||||
currentUser.value = await loadCurrentUser();
|
||||
githubUser.value = await loadGitHubUser();
|
||||
|
||||
@@ -208,7 +480,7 @@ async function handleEmailLogin(payload: { email: string; password: string }) {
|
||||
try {
|
||||
currentUser.value = await loginWithEmail(payload.email, payload.password);
|
||||
await loadAuthState();
|
||||
await reloadSite();
|
||||
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
|
||||
} catch (error) {
|
||||
loginError.value = error instanceof Error ? error.message : "登录失败";
|
||||
} finally {
|
||||
@@ -216,19 +488,67 @@ async function handleEmailLogin(payload: { email: string; password: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailRegister(payload: { email: string; name: string; password: string }) {
|
||||
isAuthLoading.value = true;
|
||||
loginError.value = "";
|
||||
|
||||
try {
|
||||
currentUser.value = await registerWithEmail(payload);
|
||||
await loadAuthState();
|
||||
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
|
||||
} catch (error) {
|
||||
loginError.value = error instanceof Error ? error.message : "注册失败";
|
||||
} finally {
|
||||
isAuthLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logoutCms();
|
||||
currentUser.value = null;
|
||||
githubUser.value = null;
|
||||
repositories.value = [];
|
||||
systemSettings.value = [];
|
||||
adminUsers.value = [];
|
||||
projects.value = [];
|
||||
projectMembers.value = [];
|
||||
activeProjectId.value = undefined;
|
||||
}
|
||||
|
||||
async function saveProfile(payload: { name: string }) {
|
||||
profileSaveState.value = "saving";
|
||||
accountMessage.value = "正在保存个人资料";
|
||||
|
||||
try {
|
||||
currentUser.value = await updateProfile(payload);
|
||||
profileSaveState.value = "saved";
|
||||
accountMessage.value = "个人资料已保存";
|
||||
} catch (error) {
|
||||
profileSaveState.value = "error";
|
||||
accountMessage.value = error instanceof Error ? error.message : "保存个人资料失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(payload: { currentPassword: string; newPassword: string }) {
|
||||
passwordSaveState.value = "saving";
|
||||
|
||||
try {
|
||||
await changePassword(payload);
|
||||
passwordSaveState.value = "saved";
|
||||
} catch (error) {
|
||||
passwordSaveState.value = "error";
|
||||
accountMessage.value = error instanceof Error ? error.message : "修改密码失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function connectGithub(connection: GitHubConnection) {
|
||||
const nextConnection = normalizeConnection(connection);
|
||||
connectionMessage.value = "";
|
||||
|
||||
try {
|
||||
validateConnection(nextConnection);
|
||||
} catch (error) {
|
||||
connectionMessage.value = error instanceof Error ? error.message : "连接信息不完整";
|
||||
activityItems.value = [
|
||||
error instanceof Error ? error.message : "连接信息不完整",
|
||||
...activityItems.value,
|
||||
@@ -237,6 +557,7 @@ async function connectGithub(connection: GitHubConnection) {
|
||||
}
|
||||
|
||||
isConnecting.value = true;
|
||||
connectionMessage.value = `正在读取 GitHub 仓库 ${nextConnection.owner}/${nextConnection.repo}`;
|
||||
|
||||
try {
|
||||
const [nextPages, nextSettings] = await Promise.all([
|
||||
@@ -251,10 +572,28 @@ async function connectGithub(connection: GitHubConnection) {
|
||||
Object.assign(siteSettings, nextSettings.settings);
|
||||
settingsSha.value = nextSettings.sha;
|
||||
activePanel.value = "content";
|
||||
if (activeProjectId.value) {
|
||||
const project = projects.value.find((item) => item.id === activeProjectId.value);
|
||||
if (project) {
|
||||
await saveCmsProject({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
githubOwner: githubConnection.owner,
|
||||
githubRepo: githubConnection.repo,
|
||||
githubBranch: githubConnection.branch,
|
||||
siteRoot: githubConnection.siteRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
connectionMessage.value = `已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`;
|
||||
activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value];
|
||||
} catch (error) {
|
||||
hasGitHubConnection.value = false;
|
||||
dataSource.value = "local";
|
||||
const message = error instanceof Error ? error.message : "未知错误";
|
||||
connectionMessage.value = message.includes("404") || message.includes("Not Found")
|
||||
? "未找到 VitePress 配置文件 .vitepress/config.ts。这个仓库可能还是空仓库,需要先初始化 VitePress 模板。"
|
||||
: message;
|
||||
activityItems.value = [
|
||||
"GitHub 仓库连接失败,仍保持本地测试站点模式",
|
||||
error instanceof Error ? error.message : "未知错误",
|
||||
@@ -281,11 +620,24 @@ async function signOutGithub() {
|
||||
}
|
||||
|
||||
async function createRepository(payload: { name: string; private: boolean }) {
|
||||
isCreatingRepository.value = true;
|
||||
repositoryMessage.value = `正在创建 GitHub 仓库 ${payload.name}`;
|
||||
|
||||
try {
|
||||
const repo = await createGitHubRepository(payload);
|
||||
repositories.value = [repo, ...repositories.value];
|
||||
Object.assign(githubConnection, {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
branch: repo.defaultBranch || "main",
|
||||
siteRoot: "docs",
|
||||
});
|
||||
repositoryMessage.value = `已创建 GitHub 仓库 ${repo.fullName}`;
|
||||
isCreatingRepository.value = false;
|
||||
activityItems.value = [`已创建 GitHub 仓库 ${repo.fullName}`, ...activityItems.value];
|
||||
} catch (error) {
|
||||
repositoryMessage.value = error instanceof Error ? error.message : "创建 GitHub 仓库失败";
|
||||
isCreatingRepository.value = false;
|
||||
activityItems.value = [
|
||||
error instanceof Error ? error.message : "创建 GitHub 仓库失败",
|
||||
...activityItems.value,
|
||||
@@ -293,6 +645,40 @@ async function createRepository(payload: { name: string; private: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRepository(connection: GitHubConnection) {
|
||||
const nextConnection = normalizeConnection(connection);
|
||||
connectionMessage.value = "";
|
||||
|
||||
try {
|
||||
validateConnection(nextConnection);
|
||||
} catch (error) {
|
||||
connectionMessage.value = error instanceof Error ? error.message : "连接信息不完整";
|
||||
return;
|
||||
}
|
||||
|
||||
isInitializingSite.value = true;
|
||||
connectionMessage.value = `正在初始化 VitePress 模板到 ${nextConnection.owner}/${nextConnection.repo}`;
|
||||
|
||||
try {
|
||||
const result = await initializeGitHubSite(nextConnection);
|
||||
const createdCount = result.files.filter((file) => file.status === "created").length;
|
||||
const updatedCount = result.files.filter((file) => file.status === "updated").length;
|
||||
const skippedCount = result.files.filter((file) => file.status === "skipped").length;
|
||||
const failedCount = result.files.filter((file) => file.status === "failed").length;
|
||||
connectionMessage.value = `初始化完成:创建 ${createdCount} 个文件,更新 ${updatedCount} 个文件,跳过 ${skippedCount} 个已存在文件,${failedCount} 个文件需要额外权限。正在读取仓库。`;
|
||||
activityItems.value = [
|
||||
`已初始化 VitePress 模板到 ${nextConnection.owner}/${nextConnection.repo}`,
|
||||
...activityItems.value,
|
||||
];
|
||||
await connectGithub(nextConnection);
|
||||
} catch (error) {
|
||||
connectionMessage.value = error instanceof Error ? error.message : "初始化 VitePress 模板失败";
|
||||
activityItems.value = [connectionMessage.value, ...activityItems.value];
|
||||
} finally {
|
||||
isInitializingSite.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPage(pageId: string) {
|
||||
activePageId.value = pageId;
|
||||
pageSaveState.value = "idle";
|
||||
@@ -357,6 +743,38 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAdminSettings(settings: SystemSetting[]) {
|
||||
systemSettingsSaveState.value = "saving";
|
||||
|
||||
try {
|
||||
await saveSystemSettings(settings);
|
||||
systemSettingsSaveState.value = "saved";
|
||||
authOptions.value = await loadAuthOptions();
|
||||
await loadAdminSettings();
|
||||
activityItems.value = ["已保存系统设置", ...activityItems.value];
|
||||
} catch (error) {
|
||||
systemSettingsSaveState.value = "error";
|
||||
activityItems.value = [
|
||||
error instanceof Error ? error.message : "保存系统设置失败",
|
||||
...activityItems.value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function testSmtp() {
|
||||
smtpTestState.value = "testing";
|
||||
smtpMessage.value = "正在测试 SMTP 连接";
|
||||
|
||||
try {
|
||||
const result = await testSmtpSettings();
|
||||
smtpTestState.value = "success";
|
||||
smtpMessage.value = result.greeting ? `SMTP 连接正常:${result.greeting}` : "SMTP 连接正常";
|
||||
} catch (error) {
|
||||
smtpTestState.value = "error";
|
||||
smtpMessage.value = error instanceof Error ? error.message : "SMTP 测试失败";
|
||||
}
|
||||
}
|
||||
|
||||
function publishChanges() {
|
||||
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
|
||||
}
|
||||
@@ -366,7 +784,7 @@ onMounted(async () => {
|
||||
await loadAuthState();
|
||||
|
||||
if (currentUser.value) {
|
||||
await reloadSite();
|
||||
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
|
||||
}
|
||||
} finally {
|
||||
isAuthLoading.value = false;
|
||||
@@ -377,9 +795,11 @@ onMounted(async () => {
|
||||
<template>
|
||||
<LoginView
|
||||
v-if="!currentUser"
|
||||
:auth-options="authOptions"
|
||||
:error-message="loginError"
|
||||
:is-loading="isAuthLoading"
|
||||
@email-login="handleEmailLogin"
|
||||
@email-register="handleEmailRegister"
|
||||
@github-login="startGitHubLogin"
|
||||
/>
|
||||
|
||||
@@ -411,23 +831,68 @@ onMounted(async () => {
|
||||
|
||||
<AccountPanel
|
||||
v-if="activePanel === 'account'"
|
||||
:message="accountMessage"
|
||||
:current-user="currentUser"
|
||||
:github-user="githubUser"
|
||||
:password-state="passwordSaveState"
|
||||
:profile-state="profileSaveState"
|
||||
@change-password="updatePassword"
|
||||
@github-login="startGitHubLogin"
|
||||
@github-logout="signOutGithub"
|
||||
@logout="handleLogout"
|
||||
@save-profile="saveProfile"
|
||||
/>
|
||||
<UserManagementPanel
|
||||
v-else-if="activePanel === 'users'"
|
||||
:current-user="currentUser"
|
||||
:load-state="usersLoadState"
|
||||
:message="usersMessage"
|
||||
:users="adminUsers"
|
||||
@create-user="createUser"
|
||||
@refresh-users="loadUsers"
|
||||
@update-user="updateUser"
|
||||
/>
|
||||
<ProjectPanel
|
||||
v-else-if="activePanel === 'projects'"
|
||||
:active-project-id="activeProjectId"
|
||||
:can-manage-active-project="canManageActiveProject"
|
||||
:members="projectMembers"
|
||||
:message="projectsMessage"
|
||||
:projects="projects"
|
||||
:state="projectsState"
|
||||
@add-member="addMember"
|
||||
@create-project="createCmsProject"
|
||||
@remove-member="removeMember"
|
||||
@refresh-projects="loadProjectList"
|
||||
@select-project="applyProject"
|
||||
@update-member="updateMember"
|
||||
@update-project="saveCmsProject"
|
||||
/>
|
||||
<SystemSettingsPanel
|
||||
v-else-if="activePanel === 'admin'"
|
||||
:save-state="systemSettingsSaveState"
|
||||
:settings="systemSettings"
|
||||
:smtp-message="smtpMessage"
|
||||
:smtp-state="smtpTestState"
|
||||
@save-settings="saveAdminSettings"
|
||||
@test-smtp="testSmtp"
|
||||
/>
|
||||
<ConnectPanel
|
||||
v-else-if="activePanel === 'connect'"
|
||||
:connection="githubConnection"
|
||||
:connection-message="connectionMessage"
|
||||
:data-source="dataSource"
|
||||
:github-user="githubUser"
|
||||
:has-git-hub-connection="hasGitHubConnection"
|
||||
:is-connecting="isConnecting"
|
||||
:is-creating-repository="isCreatingRepository"
|
||||
:is-initializing-site="isInitializingSite"
|
||||
:is-loading-repos="isLoadingRepos"
|
||||
:repository-message="repositoryMessage"
|
||||
:repositories="repositories"
|
||||
@connect-github="connectGithub"
|
||||
@create-repository="createRepository"
|
||||
@initialize-repository="initializeRepository"
|
||||
@load-repositories="refreshRepositories"
|
||||
@login-github="startGitHubLogin"
|
||||
@logout-github="signOutGithub"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SystemSetting } from "../types";
|
||||
|
||||
interface SettingsResponse {
|
||||
settings: SystemSetting[];
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return data?.error || `${fallback}: ${response.status}`;
|
||||
}
|
||||
|
||||
export async function loadSystemSettings() {
|
||||
const response = await fetch("/api/admin/settings");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "读取系统设置失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SettingsResponse;
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
export async function saveSystemSettings(settings: SystemSetting[]) {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "保存系统设置失败"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtpSettings() {
|
||||
const response = await fetch("/api/admin/settings/test-smtp", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "SMTP 测试失败"));
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
ok: boolean;
|
||||
greeting?: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CmsUser } from "../types";
|
||||
|
||||
interface UsersResponse {
|
||||
users: CmsUser[];
|
||||
}
|
||||
|
||||
interface UserResponse {
|
||||
user: CmsUser;
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return data?.error || `${fallback}: ${response.status}`;
|
||||
}
|
||||
|
||||
export async function loadAdminUsers() {
|
||||
const response = await fetch("/api/admin/users");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "读取用户列表失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UsersResponse;
|
||||
return data.users;
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}) {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "创建用户失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UserResponse;
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function updateAdminUser(
|
||||
id: number,
|
||||
payload: {
|
||||
role?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
const response = await fetch(`/api/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "更新用户失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UserResponse;
|
||||
return data.user;
|
||||
}
|
||||
+77
-6
@@ -1,24 +1,44 @@
|
||||
import type { CmsUser } from "../types";
|
||||
import type { AuthOptions, CmsUser } from "../types";
|
||||
|
||||
interface MeResponse {
|
||||
user: CmsUser | null;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
interface UserResponse {
|
||||
user: CmsUser;
|
||||
}
|
||||
|
||||
interface OptionsResponse {
|
||||
options: AuthOptions;
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return data?.error || `${fallback}: ${response.status}`;
|
||||
}
|
||||
|
||||
export async function loadCurrentUser() {
|
||||
const response = await fetch("/api/auth/me");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取当前用户失败:${response.status}`);
|
||||
throw new Error(await readError(response, "读取当前用户失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MeResponse;
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function loadAuthOptions() {
|
||||
const response = await fetch("/api/auth/options");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "读取登录设置失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OptionsResponse;
|
||||
return data.options;
|
||||
}
|
||||
|
||||
export async function loginWithEmail(email: string, password: string) {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
@@ -29,19 +49,70 @@ export async function loginWithEmail(email: string, password: string) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("邮箱或密码错误");
|
||||
throw new Error(await readError(response, "邮箱或密码错误"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LoginResponse;
|
||||
const data = (await response.json()) as UserResponse;
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function registerWithEmail(payload: { email: string; name: string; password: string }) {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "注册失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UserResponse;
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: { name: string }) {
|
||||
const response = await fetch("/api/auth/profile", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "更新个人资料失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UserResponse;
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function changePassword(payload: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}) {
|
||||
const response = await fetch("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "修改密码失败"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutCms() {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`退出登录失败:${response.status}`);
|
||||
throw new Error(await readError(response, "退出登录失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export async function createGitHubRepository(payload: {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建 GitHub 仓库失败:${response.status}`);
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || `创建 GitHub 仓库失败:${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as CreateRepoResponse;
|
||||
|
||||
+44
-7
@@ -1,6 +1,15 @@
|
||||
import type { DocPage, GitHubConnection, SiteSettings } from "../types";
|
||||
import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent";
|
||||
|
||||
interface InitSiteResponse {
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: "created" | "updated" | "skipped" | "failed";
|
||||
sha?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GitTreeResponse {
|
||||
tree: Array<{
|
||||
path: string;
|
||||
@@ -38,7 +47,7 @@ function siteParams(connection: GitHubConnection) {
|
||||
owner: connection.owner,
|
||||
repo: connection.repo,
|
||||
branch: connection.branch,
|
||||
siteRoot: connection.siteRoot,
|
||||
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +80,11 @@ function encodeBase64Content(content: string) {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return data?.error || `${fallback}:${response.status}`;
|
||||
}
|
||||
|
||||
async function githubFetch<T>(connection: GitHubConnection, path: string, init?: RequestInit) {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
@@ -101,7 +115,7 @@ export async function loadGitHubPages(connection: GitHubConnection) {
|
||||
const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 GitHub 页面失败:${response.status}`);
|
||||
throw new Error(await readApiError(response, "读取 GitHub 页面失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { pages: DocPage[] };
|
||||
@@ -152,14 +166,14 @@ export async function saveGitHubPage(connection: GitHubConnection, page: DocPage
|
||||
owner: connection.owner,
|
||||
repo: connection.repo,
|
||||
branch: connection.branch,
|
||||
siteRoot: connection.siteRoot,
|
||||
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||
},
|
||||
page,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`保存 GitHub 页面失败:${response.status}`);
|
||||
throw new Error(await readApiError(response, "保存 GitHub 页面失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { sha: string };
|
||||
@@ -193,7 +207,7 @@ export async function loadGitHubSettings(connection: GitHubConnection) {
|
||||
const response = await fetch(`/api/github/site/settings?${siteParams(connection).toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 GitHub 配置失败:${response.status}`);
|
||||
throw new Error(await readApiError(response, "读取 GitHub 配置失败"));
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
@@ -212,6 +226,29 @@ export async function loadGitHubSettings(connection: GitHubConnection) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializeGitHubSite(connection: GitHubConnection) {
|
||||
const response = await fetch("/api/github/site/init", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
site: {
|
||||
owner: connection.owner,
|
||||
repo: connection.repo,
|
||||
branch: connection.branch,
|
||||
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response, "初始化 VitePress 模板失败"));
|
||||
}
|
||||
|
||||
return (await response.json()) as InitSiteResponse;
|
||||
}
|
||||
|
||||
export async function saveGitHubSettings(
|
||||
connection: GitHubConnection,
|
||||
settings: SiteSettings,
|
||||
@@ -228,7 +265,7 @@ export async function saveGitHubSettings(
|
||||
owner: connection.owner,
|
||||
repo: connection.repo,
|
||||
branch: connection.branch,
|
||||
siteRoot: connection.siteRoot,
|
||||
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||
},
|
||||
settings,
|
||||
sha,
|
||||
@@ -236,7 +273,7 @@ export async function saveGitHubSettings(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`保存 GitHub 配置失败:${response.status}`);
|
||||
throw new Error(await readApiError(response, "保存 GitHub 配置失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { sha: string };
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { CmsProject, ProjectMember } from "../types";
|
||||
|
||||
interface ProjectsResponse {
|
||||
projects: CmsProject[];
|
||||
}
|
||||
|
||||
interface ProjectResponse {
|
||||
project: CmsProject;
|
||||
}
|
||||
|
||||
interface MembersResponse {
|
||||
members: ProjectMember[];
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return data?.error || `${fallback}: ${response.status}`;
|
||||
}
|
||||
|
||||
export async function loadProjects() {
|
||||
const response = await fetch("/api/projects");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "读取项目失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ProjectsResponse;
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
export async function createProject(payload: {
|
||||
name: string;
|
||||
githubOwner?: string;
|
||||
githubRepo?: string;
|
||||
githubBranch?: string;
|
||||
siteRoot?: string;
|
||||
}) {
|
||||
const response = await fetch("/api/projects", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "创建项目失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
id: number,
|
||||
payload: {
|
||||
name: string;
|
||||
githubOwner?: string;
|
||||
githubRepo?: string;
|
||||
githubBranch?: string;
|
||||
siteRoot?: string;
|
||||
},
|
||||
) {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "更新项目失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function loadProjectMembers(projectId: number) {
|
||||
const response = await fetch(`/api/projects/${projectId}/members`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "读取项目成员失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MembersResponse;
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function addProjectMember(projectId: number, payload: { email: string; role: string }) {
|
||||
const response = await fetch(`/api/projects/${projectId}/members`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "添加项目成员失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MembersResponse;
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function updateProjectMember(projectId: number, memberId: number, payload: { role: string }) {
|
||||
const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "更新项目成员失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MembersResponse;
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function removeProjectMember(projectId: number, memberId: number) {
|
||||
const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "移除项目成员失败"));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MembersResponse;
|
||||
return data.members;
|
||||
}
|
||||
+120
-18
@@ -1,40 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from "vue";
|
||||
import type { CmsUser, GitHubUser } from "../types";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
currentUser: CmsUser;
|
||||
githubUser: GitHubUser | null;
|
||||
profileState: "idle" | "saving" | "saved" | "error";
|
||||
passwordState: "idle" | "saving" | "saved" | "error";
|
||||
message: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
githubLogin: [];
|
||||
githubLogout: [];
|
||||
logout: [];
|
||||
saveProfile: [payload: { name: string }];
|
||||
changePassword: [payload: { currentPassword: string; newPassword: string }];
|
||||
}>();
|
||||
|
||||
const profileDraft = reactive({
|
||||
name: props.currentUser.name,
|
||||
});
|
||||
|
||||
const passwordDraft = reactive({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.currentUser,
|
||||
(user) => {
|
||||
profileDraft.name = user.name;
|
||||
},
|
||||
);
|
||||
|
||||
function submitProfile() {
|
||||
emit("saveProfile", {
|
||||
name: profileDraft.name.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
function submitPassword() {
|
||||
if (!passwordDraft.newPassword || passwordDraft.newPassword !== passwordDraft.confirmPassword) return;
|
||||
|
||||
emit("changePassword", {
|
||||
currentPassword: passwordDraft.currentPassword,
|
||||
newPassword: passwordDraft.newPassword,
|
||||
});
|
||||
passwordDraft.currentPassword = "";
|
||||
passwordDraft.newPassword = "";
|
||||
passwordDraft.confirmPassword = "";
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel is-visible">
|
||||
<div class="account-layout">
|
||||
<div class="account-grid">
|
||||
<section class="management-card">
|
||||
<p class="eyebrow">account</p>
|
||||
<h3>用户中心</h3>
|
||||
<p class="eyebrow">profile</p>
|
||||
<h3>个人资料</h3>
|
||||
<div class="profile-card">
|
||||
<span class="user-avatar">{{ currentUser.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<div>
|
||||
<strong>{{ currentUser.name }}</strong>
|
||||
<small>{{ currentUser.email || "未绑定邮箱" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid single-column">
|
||||
<label>
|
||||
昵称
|
||||
<input v-model="profileDraft.name" autocomplete="name" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="helper-text">{{ message }}</p>
|
||||
<div class="connect-actions">
|
||||
<button class="primary-button" type="button" :disabled="profileState === 'saving'" @click="submitProfile">
|
||||
{{ profileState === "saving" ? "保存中" : "保存资料" }}
|
||||
</button>
|
||||
<button class="ghost-button" type="button" @click="$emit('logout')">退出登录</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="management-card">
|
||||
<p class="eyebrow">security</p>
|
||||
<h3>安全信息</h3>
|
||||
<dl class="source-summary account-summary">
|
||||
<div>
|
||||
<dt>名称</dt>
|
||||
<dd>{{ currentUser.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>邮箱</dt>
|
||||
<dd>{{ currentUser.email || "-" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>角色</dt>
|
||||
<dd>{{ currentUser.role }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{{ currentUser.status === "disabled" ? "已禁用" : "正常" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>权限</dt>
|
||||
<dd>{{ currentUser.permissions?.length || 0 }} 项</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最后登录</dt>
|
||||
<dd>{{ formatDate(currentUser.lastLoginAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button class="ghost-button account-logout" type="button" @click="$emit('logout')">
|
||||
退出 CMS 登录
|
||||
</section>
|
||||
|
||||
<section class="management-card">
|
||||
<p class="eyebrow">password</p>
|
||||
<h3>修改密码</h3>
|
||||
<div class="form-grid single-column">
|
||||
<label>
|
||||
当前密码
|
||||
<input v-model="passwordDraft.currentPassword" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
新密码
|
||||
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label>
|
||||
确认新密码
|
||||
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="helper-text">
|
||||
<template v-if="passwordState === 'saved'">密码已更新。</template>
|
||||
<template v-else-if="passwordState === 'error'">密码更新失败,请检查当前密码。</template>
|
||||
<template v-else>新密码至少 8 位,确认密码需要一致。</template>
|
||||
</p>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="passwordState === 'saving' || !passwordDraft.newPassword || passwordDraft.newPassword !== passwordDraft.confirmPassword"
|
||||
@click="submitPassword"
|
||||
>
|
||||
{{ passwordState === "saving" ? "更新中" : "更新密码" }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -51,9 +155,7 @@ defineEmits<{
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="helper-text">绑定 GitHub 后,可以读取仓库列表、选择站点仓库并提交内容变更。</p>
|
||||
<button class="primary-button" type="button" @click="$emit('githubLogin')">
|
||||
绑定 GitHub
|
||||
</button>
|
||||
<button class="primary-button" type="button" @click="$emit('githubLogin')">绑定 GitHub</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,22 @@ import type { DataSource, GitHubConnection, GitHubRepository, GitHubUser } from
|
||||
|
||||
const props = defineProps<{
|
||||
connection: GitHubConnection;
|
||||
connectionMessage: string;
|
||||
dataSource: DataSource;
|
||||
githubUser: GitHubUser | null;
|
||||
hasGitHubConnection: boolean;
|
||||
isConnecting: boolean;
|
||||
isCreatingRepository: boolean;
|
||||
isInitializingSite: boolean;
|
||||
isLoadingRepos: boolean;
|
||||
repositoryMessage: string;
|
||||
repositories: GitHubRepository[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
connectGithub: [connection: GitHubConnection];
|
||||
createRepository: [payload: { name: string; private: boolean }];
|
||||
initializeRepository: [connection: GitHubConnection];
|
||||
loginGithub: [];
|
||||
loadRepositories: [];
|
||||
logoutGithub: [];
|
||||
@@ -64,6 +69,11 @@ function createRepository() {
|
||||
private: newRepo.private,
|
||||
});
|
||||
}
|
||||
|
||||
function initializeRepository() {
|
||||
if (!canConnect.value) return;
|
||||
emit("initializeRepository", { ...draft });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +123,7 @@ function createRepository() {
|
||||
</label>
|
||||
<label>
|
||||
站点目录
|
||||
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
|
||||
<input v-model="draft.siteRoot" placeholder="默认 docs" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +131,16 @@ function createRepository() {
|
||||
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
|
||||
{{ isConnecting ? "连接中" : "读取选中仓库" }}
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="!canConnect || isInitializingSite"
|
||||
@click="initializeRepository"
|
||||
>
|
||||
{{ isInitializingSite ? "初始化中" : "初始化 VitePress 模板" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="connectionMessage" class="helper-text">{{ connectionMessage }}</p>
|
||||
|
||||
<div class="new-repo-box">
|
||||
<p class="eyebrow">create</p>
|
||||
@@ -136,8 +155,9 @@ function createRepository() {
|
||||
<input v-model="newRepo.private" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" :disabled="!canCreateRepo" @click="createRepository">
|
||||
创建仓库
|
||||
<p v-if="repositoryMessage" class="helper-text">{{ repositoryMessage }}</p>
|
||||
<button class="secondary-button" type="button" :disabled="!canCreateRepo || isCreatingRepository" @click="createRepository">
|
||||
{{ isCreatingRepository ? "创建中" : "创建仓库" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -165,7 +185,7 @@ function createRepository() {
|
||||
</label>
|
||||
<label class="wide-field">
|
||||
站点目录
|
||||
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
|
||||
<input v-model="draft.siteRoot" placeholder="默认 docs" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import type { AuthOptions } from "../types";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
authOptions: AuthOptions;
|
||||
errorMessage: string;
|
||||
isLoading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
emailLogin: [payload: { email: string; password: string }];
|
||||
emailRegister: [payload: { email: string; name: string; password: string }];
|
||||
githubLogin: [];
|
||||
}>();
|
||||
|
||||
const mode = reactive({
|
||||
value: "login" as "login" | "register",
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
email: "admin@example.com",
|
||||
name: "",
|
||||
password: "admin123456",
|
||||
});
|
||||
|
||||
function submit() {
|
||||
emit("emailLogin", { ...form });
|
||||
const submitMode = !props.authOptions.allowEmailLogin && props.authOptions.allowRegistration ? "register" : mode.value;
|
||||
|
||||
if (submitMode === "register") {
|
||||
emit("emailRegister", {
|
||||
email: form.email,
|
||||
name: form.name,
|
||||
password: form.password,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emit("emailLogin", {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -32,27 +54,45 @@ function submit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="login-form" @submit.prevent="submit">
|
||||
<div class="segmented-control">
|
||||
<button v-if="authOptions.allowEmailLogin" type="button" :class="{ 'is-active': mode.value === 'login' }" @click="mode.value = 'login'">
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
v-if="authOptions.allowRegistration"
|
||||
type="button"
|
||||
:class="{ 'is-active': mode.value === 'register' }"
|
||||
@click="mode.value = 'register'"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="authOptions.allowEmailLogin || authOptions.allowRegistration" class="login-form" @submit.prevent="submit">
|
||||
<label>
|
||||
邮箱
|
||||
<input v-model="form.email" type="email" autocomplete="username" />
|
||||
</label>
|
||||
<label v-if="mode.value === 'register' || (!authOptions.allowEmailLogin && authOptions.allowRegistration)">
|
||||
昵称
|
||||
<input v-model="form.name" autocomplete="name" />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input v-model="form.password" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button class="primary-button" type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? "登录中" : "邮箱登录" }}
|
||||
{{ isLoading ? "处理中" : mode.value === "register" || (!authOptions.allowEmailLogin && authOptions.allowRegistration) ? "创建账号" : "邮箱登录" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button class="connect-button" type="button" @click="$emit('githubLogin')">
|
||||
<button v-if="authOptions.allowGitHubLogin" class="connect-button" type="button" @click="$emit('githubLogin')">
|
||||
<span>GitHub</span>
|
||||
<strong>使用 GitHub 登录</strong>
|
||||
</button>
|
||||
|
||||
<p v-if="errorMessage" class="save-status is-error">{{ errorMessage }}</p>
|
||||
<p class="helper-text">默认管理员账号:admin@example.com / admin123456</p>
|
||||
<p v-if="!authOptions.allowRegistration" class="helper-text">默认管理员账号:admin@example.com / admin123456。注册入口当前已关闭。</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from "vue";
|
||||
import type { CmsProject, ProjectMember } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
activeProjectId?: number;
|
||||
canManageActiveProject: boolean;
|
||||
members: ProjectMember[];
|
||||
projects: CmsProject[];
|
||||
message: string;
|
||||
state: "idle" | "loading" | "saving" | "error";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
createProject: [payload: { name: string; githubOwner?: string; githubRepo?: string; githubBranch?: string; siteRoot?: string }];
|
||||
addMember: [payload: { email: string; role: string }];
|
||||
refreshProjects: [];
|
||||
removeMember: [memberId: number];
|
||||
selectProject: [project: CmsProject];
|
||||
updateMember: [payload: { id: number; role: string }];
|
||||
updateProject: [payload: { id: number; name: string; githubOwner?: string; githubRepo?: string; githubBranch?: string; siteRoot?: string }];
|
||||
}>();
|
||||
|
||||
const draft = reactive({
|
||||
id: 0,
|
||||
name: "",
|
||||
githubOwner: "",
|
||||
githubRepo: "",
|
||||
githubBranch: "main",
|
||||
siteRoot: "docs",
|
||||
});
|
||||
|
||||
const memberDraft = reactive({
|
||||
email: "",
|
||||
role: "editor",
|
||||
});
|
||||
|
||||
const memberRoles = [
|
||||
{ label: "Owner", value: "owner" },
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "Editor", value: "editor" },
|
||||
{ label: "Viewer", value: "viewer" },
|
||||
];
|
||||
|
||||
watch(
|
||||
() => props.projects.find((project) => project.id === props.activeProjectId),
|
||||
(project) => {
|
||||
if (!project) return;
|
||||
draft.id = project.id;
|
||||
draft.name = project.name;
|
||||
draft.githubOwner = project.githubOwner || "";
|
||||
draft.githubRepo = project.githubRepo || "";
|
||||
draft.githubBranch = project.githubBranch || "main";
|
||||
draft.siteRoot = project.siteRoot || "docs";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
githubOwner: draft.githubOwner.trim(),
|
||||
githubRepo: draft.githubRepo.trim(),
|
||||
githubBranch: draft.githubBranch.trim() || "main",
|
||||
siteRoot: draft.siteRoot.trim() || "docs",
|
||||
};
|
||||
}
|
||||
|
||||
function addMember() {
|
||||
if (!memberDraft.email.trim()) return;
|
||||
emit("addMember", {
|
||||
email: memberDraft.email.trim(),
|
||||
role: memberDraft.role,
|
||||
});
|
||||
memberDraft.email = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel is-visible">
|
||||
<div class="project-layout">
|
||||
<section class="management-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">projects</p>
|
||||
<h3>项目列表</h3>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" :disabled="state === 'loading'" @click="$emit('refreshProjects')">
|
||||
{{ state === "loading" ? "刷新中" : "刷新" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="project-list">
|
||||
<button
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="project-item"
|
||||
:class="{ 'is-active': project.id === activeProjectId }"
|
||||
type="button"
|
||||
@click="$emit('selectProject', project)"
|
||||
>
|
||||
<strong>{{ project.name }}</strong>
|
||||
<span>{{ project.githubOwner && project.githubRepo ? `${project.githubOwner}/${project.githubRepo}` : "未绑定仓库" }}</span>
|
||||
</button>
|
||||
<p v-if="!projects.length" class="helper-text">暂无项目。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="management-card">
|
||||
<p class="eyebrow">details</p>
|
||||
<h3>项目配置</h3>
|
||||
<div class="form-grid">
|
||||
<label class="wide-field">
|
||||
项目名称
|
||||
<input v-model="draft.name" placeholder="文档站点" />
|
||||
</label>
|
||||
<label>
|
||||
GitHub Owner
|
||||
<input v-model="draft.githubOwner" placeholder="octocat" />
|
||||
</label>
|
||||
<label>
|
||||
GitHub Repo
|
||||
<input v-model="draft.githubRepo" placeholder="docs-site" />
|
||||
</label>
|
||||
<label>
|
||||
Branch
|
||||
<input v-model="draft.githubBranch" placeholder="main" />
|
||||
</label>
|
||||
<label>
|
||||
站点目录
|
||||
<input v-model="draft.siteRoot" placeholder="docs" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="helper-text" :class="{ 'is-error': state === 'error' }">{{ message || "项目用于保存仓库、分支和站点目录。" }}</p>
|
||||
<p v-if="!canManageActiveProject && activeProjectId" class="helper-text">
|
||||
当前账号可以查看此项目,只有项目 Owner、项目 Admin 或系统管理员可以修改项目配置。
|
||||
</p>
|
||||
<div class="connect-actions">
|
||||
<button class="primary-button" type="button" :disabled="state === 'saving' || !draft.name.trim()" @click="$emit('createProject', payload())">
|
||||
新建项目
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="state === 'saving' || !draft.id || !draft.name.trim() || !canManageActiveProject"
|
||||
@click="$emit('updateProject', { id: draft.id, ...payload() })"
|
||||
>
|
||||
保存项目
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="management-card wide-card">
|
||||
<p class="eyebrow">members</p>
|
||||
<h3>项目成员</h3>
|
||||
<div class="member-editor">
|
||||
<label>
|
||||
用户邮箱
|
||||
<input v-model="memberDraft.email" placeholder="user@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
角色
|
||||
<select v-model="memberDraft.role">
|
||||
<option v-for="role in memberRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary-button" type="button" :disabled="!activeProjectId || !memberDraft.email.trim() || !canManageActiveProject" @click="addMember">
|
||||
添加成员
|
||||
</button>
|
||||
</div>
|
||||
<div class="member-list">
|
||||
<div v-for="member in members" :key="member.id" class="member-row">
|
||||
<div class="user-identity">
|
||||
<span class="user-avatar">{{ member.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<div>
|
||||
<strong>{{ member.name }}</strong>
|
||||
<small>{{ member.email || "未绑定邮箱" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
:disabled="!canManageActiveProject"
|
||||
:value="member.role"
|
||||
@change="$emit('updateMember', { id: member.id, role: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option v-for="role in memberRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="ghost-button" type="button" :disabled="!canManageActiveProject" @click="$emit('removeMember', member.id)">
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!members.length" class="helper-text">暂无项目成员。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from "vue";
|
||||
import type { SystemSetting } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
settings: SystemSetting[];
|
||||
saveState: "idle" | "saving" | "saved" | "error";
|
||||
smtpState: "idle" | "testing" | "success" | "error";
|
||||
smtpMessage: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
saveSettings: [settings: SystemSetting[]];
|
||||
testSmtp: [];
|
||||
}>();
|
||||
|
||||
const draft = reactive<Record<string, SystemSetting>>({});
|
||||
|
||||
const groups = [
|
||||
{
|
||||
title: "基础",
|
||||
eyebrow: "general",
|
||||
keys: ["site.title"],
|
||||
},
|
||||
{
|
||||
title: "认证",
|
||||
eyebrow: "auth",
|
||||
keys: ["auth.allow_email_login", "auth.allow_github_login", "auth.allow_registration"],
|
||||
},
|
||||
{
|
||||
title: "SMTP",
|
||||
eyebrow: "mail",
|
||||
keys: ["smtp.host", "smtp.port", "smtp.username", "smtp.password", "smtp.sender_email"],
|
||||
},
|
||||
{
|
||||
title: "GitHub",
|
||||
eyebrow: "github",
|
||||
keys: ["github.api_base_url", "github.web_base_url", "github.oauth_client_id", "github.oauth_client_secret"],
|
||||
},
|
||||
];
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
"site.title": "CMS 标题",
|
||||
"auth.allow_email_login": "允许邮箱登录",
|
||||
"auth.allow_github_login": "允许 GitHub 登录",
|
||||
"auth.allow_registration": "开放注册",
|
||||
"smtp.host": "SMTP 地址",
|
||||
"smtp.port": "SMTP 端口",
|
||||
"smtp.username": "SMTP 用户名",
|
||||
"smtp.password": "SMTP 密码",
|
||||
"smtp.sender_email": "发件人邮箱",
|
||||
"github.api_base_url": "GitHub API 地址",
|
||||
"github.web_base_url": "GitHub Web 地址",
|
||||
"github.oauth_client_id": "OAuth Client ID",
|
||||
"github.oauth_client_secret": "OAuth Client Secret",
|
||||
};
|
||||
|
||||
const booleanKeys = new Set([
|
||||
"auth.allow_email_login",
|
||||
"auth.allow_github_login",
|
||||
"auth.allow_registration",
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => props.settings,
|
||||
(settings) => {
|
||||
Object.keys(draft).forEach((key) => delete draft[key]);
|
||||
settings.forEach((setting) => {
|
||||
draft[setting.key] = { ...setting };
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const draftSettings = computed(() => Object.values(draft));
|
||||
|
||||
function getSetting(key: string) {
|
||||
if (!draft[key]) {
|
||||
draft[key] = {
|
||||
key,
|
||||
value: "",
|
||||
encrypted: key.includes("password") || key.includes("secret"),
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
return draft[key];
|
||||
}
|
||||
|
||||
function isBooleanKey(key: string) {
|
||||
return booleanKeys.has(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel is-visible">
|
||||
<div class="settings-toolbar">
|
||||
<p class="save-status" :class="`is-${saveState}`">
|
||||
<template v-if="saveState === 'saved'">系统设置已保存</template>
|
||||
<template v-else-if="saveState === 'error'">保存失败,请查看日志</template>
|
||||
<template v-else-if="saveState === 'saving'">正在保存系统设置</template>
|
||||
<template v-else>系统设置保存到 SQLite 数据库</template>
|
||||
</p>
|
||||
<button class="primary-button" type="button" @click="$emit('saveSettings', draftSettings)">
|
||||
{{ saveState === "saving" ? "保存中" : "保存设置" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section v-for="group in groups" :key="group.title" class="management-card">
|
||||
<p class="eyebrow">{{ group.eyebrow }}</p>
|
||||
<h3>{{ group.title }}</h3>
|
||||
<label
|
||||
v-for="key in group.keys"
|
||||
:key="key"
|
||||
:class="{ 'toggle-row': isBooleanKey(key) }"
|
||||
>
|
||||
<span>{{ labels[key] || key }}</span>
|
||||
<input
|
||||
v-if="isBooleanKey(key)"
|
||||
:checked="getSetting(key).value === 'true'"
|
||||
type="checkbox"
|
||||
@change="getSetting(key).value = ($event.target as HTMLInputElement).checked ? 'true' : 'false'"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="getSetting(key).value"
|
||||
:placeholder="getSetting(key).encrypted ? '留空表示不修改敏感值' : ''"
|
||||
:type="getSetting(key).encrypted ? 'password' : 'text'"
|
||||
/>
|
||||
</label>
|
||||
<template v-if="group.eyebrow === 'mail'">
|
||||
<p class="helper-text" :class="{ 'is-error': smtpState === 'error', 'is-success': smtpState === 'success' }">
|
||||
{{ smtpMessage || "保存 SMTP 设置后,可以测试服务器连通性。" }}
|
||||
</p>
|
||||
<button class="secondary-button" type="button" :disabled="smtpState === 'testing'" @click="$emit('testSmtp')">
|
||||
{{ smtpState === "testing" ? "测试中" : "测试 SMTP" }}
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import type { CmsUser } from "../types";
|
||||
|
||||
defineProps<{
|
||||
currentUser: CmsUser;
|
||||
users: CmsUser[];
|
||||
loadState: "idle" | "loading" | "error";
|
||||
message: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
createUser: [payload: { email: string; name: string; password: string; role: string }];
|
||||
refreshUsers: [];
|
||||
updateUser: [payload: { id: number; role?: string; status?: string }];
|
||||
}>();
|
||||
|
||||
const roleOptions = [
|
||||
{ label: "系统管理员", value: "system_admin" },
|
||||
{ label: "管理员", value: "admin" },
|
||||
{ label: "编辑者", value: "editor" },
|
||||
{ label: "查看者", value: "viewer" },
|
||||
{ label: "普通用户", value: "user" },
|
||||
];
|
||||
|
||||
const createDraft = reactive({
|
||||
email: "",
|
||||
name: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
});
|
||||
|
||||
function submitCreateUser() {
|
||||
if (!createDraft.email.trim() || !createDraft.name.trim() || createDraft.password.length < 8) return;
|
||||
|
||||
emit("createUser", {
|
||||
email: createDraft.email.trim(),
|
||||
name: createDraft.name.trim(),
|
||||
password: createDraft.password,
|
||||
role: createDraft.role,
|
||||
});
|
||||
|
||||
createDraft.email = "";
|
||||
createDraft.name = "";
|
||||
createDraft.password = "";
|
||||
createDraft.role = "user";
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return roleOptions.find((item) => item.value === role)?.label || role;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel is-visible">
|
||||
<div class="settings-toolbar">
|
||||
<p class="save-status" :class="{ 'is-error': loadState === 'error' }">
|
||||
{{ message || "管理后台用户、角色和账号状态" }}
|
||||
</p>
|
||||
<button class="secondary-button" type="button" :disabled="loadState === 'loading'" @click="$emit('refreshUsers')">
|
||||
{{ loadState === "loading" ? "刷新中" : "刷新用户" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="management-card wide-card user-create-card">
|
||||
<p class="eyebrow">create</p>
|
||||
<h3>创建用户</h3>
|
||||
<div class="user-create-grid">
|
||||
<label>
|
||||
邮箱
|
||||
<input v-model="createDraft.email" type="email" placeholder="user@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
昵称
|
||||
<input v-model="createDraft.name" placeholder="编辑成员" />
|
||||
</label>
|
||||
<label>
|
||||
初始密码
|
||||
<input v-model="createDraft.password" type="password" placeholder="至少 8 位" />
|
||||
</label>
|
||||
<label>
|
||||
角色
|
||||
<select v-model="createDraft.role">
|
||||
<option v-for="role in roleOptions" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="loadState === 'loading' || !createDraft.email.trim() || !createDraft.name.trim() || createDraft.password.length < 8"
|
||||
@click="submitCreateUser"
|
||||
>
|
||||
创建用户
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="management-card wide-card">
|
||||
<p class="eyebrow">users</p>
|
||||
<h3>用户与权限</h3>
|
||||
<div class="user-table">
|
||||
<div class="user-table-head">
|
||||
<span>用户</span>
|
||||
<span>角色</span>
|
||||
<span>状态</span>
|
||||
<span>最后登录</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div v-for="user in users" :key="user.id" class="user-table-row">
|
||||
<div class="user-identity">
|
||||
<span class="user-avatar">{{ user.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<div>
|
||||
<strong>{{ user.name }}</strong>
|
||||
<small>{{ user.email || "未绑定邮箱" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
:disabled="user.id === currentUser.id && user.role === 'system_admin'"
|
||||
:value="user.role"
|
||||
@change="$emit('updateUser', { id: user.id, role: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option v-for="role in roleOptions" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span :class="['status-pill', user.status === 'disabled' ? 'is-disabled' : 'is-active']">
|
||||
{{ user.status === "disabled" ? "已禁用" : "正常" }}
|
||||
</span>
|
||||
<span>{{ formatDate(user.lastLoginAt) }}</span>
|
||||
<button
|
||||
class="ghost-button"
|
||||
type="button"
|
||||
:disabled="user.id === currentUser.id"
|
||||
@click="$emit('updateUser', { id: user.id, status: user.status === 'disabled' ? 'active' : 'disabled' })"
|
||||
>
|
||||
{{ user.status === "disabled" ? "启用" : "禁用" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!users.length" class="helper-text">暂无用户。</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
+181
-1
@@ -498,6 +498,16 @@ label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.helper-text.is-error {
|
||||
color: #a64038;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.helper-text.is-success {
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -543,6 +553,8 @@ label {
|
||||
.two-column,
|
||||
.publish-layout,
|
||||
.account-layout,
|
||||
.account-grid,
|
||||
.project-layout,
|
||||
.connect-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -563,6 +575,10 @@ label {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.single-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -614,10 +630,163 @@ label {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.profile-card strong,
|
||||
.profile-card small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-card small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-soft);
|
||||
padding: 12px;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-item.is-active {
|
||||
border-color: #b9d8d4;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.project-item span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(140px, 0.4fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.member-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(140px, 0.4fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.account-logout {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.user-create-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(140px, 0.7fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.user-table-head,
|
||||
.user-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.6fr) minmax(150px, 0.8fr) minmax(90px, 0.5fr) minmax(160px, 0.9fr) minmax(90px, 0.4fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-table-head {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-table-row {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.user-identity {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-identity strong,
|
||||
.user-identity small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-identity small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0 10px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-pill.is-disabled {
|
||||
background: #f4e4e2;
|
||||
color: #9a3d36;
|
||||
}
|
||||
|
||||
.source-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -747,12 +916,23 @@ label {
|
||||
.two-column,
|
||||
.publish-layout,
|
||||
.account-layout,
|
||||
.account-grid,
|
||||
.project-layout,
|
||||
.connect-layout,
|
||||
.settings-grid,
|
||||
.frontmatter-grid,
|
||||
.form-grid {
|
||||
.form-grid,
|
||||
.user-create-grid,
|
||||
.user-table-head,
|
||||
.user-table-row,
|
||||
.member-editor,
|
||||
.member-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-table-head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
+45
-2
@@ -1,4 +1,4 @@
|
||||
export type PanelKey = "account" | "connect" | "content" | "structure" | "config" | "publish";
|
||||
export type PanelKey = "account" | "projects" | "users" | "admin" | "connect" | "content" | "structure" | "config" | "publish";
|
||||
export type EditorMode = "write" | "preview";
|
||||
export type DataSource = "local" | "github";
|
||||
|
||||
@@ -57,7 +57,19 @@ export interface CmsUser {
|
||||
email?: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
role: "system_admin" | "user" | string;
|
||||
role: "system_admin" | "admin" | "editor" | "viewer" | "user" | string;
|
||||
status?: "active" | "disabled" | string;
|
||||
emailVerified?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
lastLoginAt?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface AuthOptions {
|
||||
allowEmailLogin: boolean;
|
||||
allowGitHubLogin: boolean;
|
||||
allowRegistration: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubRepository {
|
||||
@@ -69,3 +81,34 @@ export interface GitHubRepository {
|
||||
defaultBranch: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CmsProject {
|
||||
id: number;
|
||||
name: string;
|
||||
githubOwner?: string;
|
||||
githubRepo?: string;
|
||||
githubBranch: string;
|
||||
siteRoot: string;
|
||||
ownerUserId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
id: number;
|
||||
projectId: number;
|
||||
userId: number;
|
||||
role: "owner" | "admin" | "editor" | "viewer" | string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SystemSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
encrypted: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CmsUser } from "../types";
|
||||
|
||||
export const permissions = {
|
||||
settingsManage: "settings.manage",
|
||||
usersManage: "users.manage",
|
||||
projectsManage: "projects.manage",
|
||||
contentEdit: "content.edit",
|
||||
contentPublish: "content.publish",
|
||||
contentView: "content.view",
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof permissions)[keyof typeof permissions];
|
||||
|
||||
const rolePermissions: Record<string, Permission[]> = {
|
||||
system_admin: Object.values(permissions),
|
||||
admin: [
|
||||
permissions.usersManage,
|
||||
permissions.projectsManage,
|
||||
permissions.contentEdit,
|
||||
permissions.contentPublish,
|
||||
permissions.contentView,
|
||||
],
|
||||
editor: [permissions.contentEdit, permissions.contentPublish, permissions.contentView],
|
||||
viewer: [permissions.contentView],
|
||||
user: [permissions.contentView],
|
||||
};
|
||||
|
||||
export function hasPermission(user: CmsUser | null | undefined, permission: Permission) {
|
||||
if (!user) return false;
|
||||
return Boolean(user.permissions?.includes(permission) || rolePermissions[user.role]?.includes(permission));
|
||||
}
|
||||
Reference in New Issue
Block a user