Compare commits

...

3 Commits

Author SHA1 Message Date
Coldsmile_7 49c86bc14a 功能:完善用户与项目管理框架 2026-06-13 00:51:52 +08:00
Coldsmile_7 db6d6de74a feat: add GitHub site initialization 2026-06-12 00:02:23 +08:00
Coldsmile_7 d5f7e06e4e feat: add system settings panel 2026-06-08 22:50:18 +08:00
27 changed files with 4374 additions and 150 deletions
+1192 -54
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0"
}, },
"dependencies": { "dependencies": {
"vitepress": "^1.6.4",
"vue": "^3.5.0" "vue": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
+179 -3
View File
@@ -1,9 +1,10 @@
import { db } from "./db.mjs"; import { db } from "./db.mjs";
import { json, readBody } from "./http.mjs"; import { json, readBody } from "./http.mjs";
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.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; if (!user) return null;
return { return {
@@ -12,6 +13,12 @@ function publicUser(user) {
name: user.name, name: user.name,
avatarUrl: user.avatar_url, avatarUrl: user.avatar_url,
role: user.role, 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; 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) { async function handleMe(request, response) {
const { session } = getRequestSession(request); const { session } = getRequestSession(request);
json(response, 200, { user: session?.user ?? null }); 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) { 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 body = JSON.parse(await readBody(request) || "{}");
const email = String(body.email || "").trim().toLowerCase(); const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || ""); const password = String(body.password || "");
@@ -55,7 +99,15 @@ async function handleLogin(request, response) {
return; 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({ const sessionId = createSession({
user: sessionUser, user: sessionUser,
}); });
@@ -63,12 +115,116 @@ async function handleLogin(request, response) {
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) }); 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) { async function handleLogout(request, response) {
const { sessionId } = getRequestSession(request); const { sessionId } = getRequestSession(request);
deleteSession(sessionId); deleteSession(sessionId);
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() }); 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) { export async function handleAuthApi(request, response, url) {
try { try {
if (url.pathname === "/api/auth/me" && request.method === "GET") { if (url.pathname === "/api/auth/me" && request.method === "GET") {
@@ -76,16 +232,36 @@ export async function handleAuthApi(request, response, url) {
return true; 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") { if (url.pathname === "/api/auth/login" && request.method === "POST") {
await handleLogin(request, response); await handleLogin(request, response);
return true; 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") { if (url.pathname === "/api/auth/logout" && request.method === "POST") {
await handleLogout(request, response); await handleLogout(request, response);
return true; 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; return false;
} catch (error) { } catch (error) {
json(response, 500, { json(response, 500, {
+4 -2
View File
@@ -1,4 +1,4 @@
import { db, getSetting, migrate, setSetting } from "./db.mjs"; import { db, migrate, setSetting } from "./db.mjs";
import { hashPassword } from "./security.mjs"; import { hashPassword } from "./security.mjs";
const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com"; const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com";
@@ -33,7 +33,9 @@ function seedSettings() {
}; };
Object.entries(seeds).forEach(([key, value]) => { 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")); setSetting(key, value, key.includes("secret") || key.includes("password"));
} }
}); });
+10
View File
@@ -67,6 +67,16 @@ export function migrate() {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 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 = "") { export function getSetting(key, fallback = "") {
+583 -21
View File
@@ -1,10 +1,202 @@
import { randomBytes } from "node:crypto"; 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 { json, readBody, redirect } from "./http.mjs";
import { getRequestSession } from "./auth.mjs"; import { getRequestSession } from "./auth.mjs";
import { createSession, sessionCookie, updateSession } from "./session.mjs"; import { createSession, sessionCookie, updateSession } from "./session.mjs";
const oauthStates = new Map(); 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) { function getBaseUrl(request) {
const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL; const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL;
@@ -15,39 +207,64 @@ function getBaseUrl(request) {
return `${protocol}://${host}`; 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) { function getOAuthConfig(request) {
const clientId = process.env.GITHUB_CLIENT_ID; const config = getGithubConfig();
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
const baseUrl = getBaseUrl(request); 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"); throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET");
} }
return { return {
clientId, ...config,
clientSecret,
redirectUri: `${baseUrl}/api/github/callback`, redirectUri: `${baseUrl}/api/github/callback`,
}; };
} }
async function githubFetch(session, path, init = {}) { 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, ...init,
headers: { headers: {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
Authorization: `Bearer ${session.accessToken}`, Authorization: `Bearer ${session.accessToken}`,
"User-Agent": githubUserAgent,
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
...init.headers, ...init.headers,
}, },
}); });
if (!response.ok) { if (!response.ok) {
const details = await response.text(); const details = response.text;
throw new Error(`GitHub API error ${response.status}: ${details}`); 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) { function requireSession(request, response) {
@@ -71,6 +288,11 @@ function publicCmsUser(user) {
name: user.name, name: user.name,
avatarUrl: user.avatar_url, avatarUrl: user.avatar_url,
role: user.role, 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) { async function handleLogin(request, response) {
const { clientId, redirectUri } = getOAuthConfig(request); const { clientId, redirectUri, webBaseUrl } = getOAuthConfig(request);
const state = randomBytes(16).toString("hex"); const state = randomBytes(16).toString("hex");
oauthStates.set(state, Date.now()); oauthStates.set(state, Date.now());
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: clientId, client_id: clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
scope: "repo", scope: "repo workflow",
state, 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) { async function handleCallback(request, response, url) {
@@ -144,12 +366,13 @@ async function handleCallback(request, response, url) {
} }
oauthStates.delete(state); oauthStates.delete(state);
const { clientId, clientSecret, redirectUri } = getOAuthConfig(request); const { clientId, clientSecret, redirectUri, apiBaseUrl, webBaseUrl } = getOAuthConfig(request);
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { const tokenResponse = await requestJson(`${webBaseUrl}/login/oauth/access_token`, {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": githubUserAgent,
}, },
body: JSON.stringify({ body: JSON.stringify({
client_id: clientId, client_id: clientId,
@@ -158,25 +381,37 @@ async function handleCallback(request, response, url) {
redirect_uri: redirectUri, redirect_uri: redirectUri,
}), }),
}); });
const tokenPayload = await tokenResponse.json(); const tokenPayload = tokenResponse.payload ?? {};
if (!tokenPayload.access_token) { if (!tokenPayload.access_token) {
redirect(response, "/?github=token_error"); redirect(response, "/?github=token_error");
return; return;
} }
const userResponse = await fetch("https://api.github.com/user", { const userResponse = await requestJson(`${apiBaseUrl}/user`, {
headers: { headers: {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
Authorization: `Bearer ${tokenPayload.access_token}`, Authorization: `Bearer ${tokenPayload.access_token}`,
"User-Agent": githubUserAgent,
"X-GitHub-Api-Version": "2022-11-28", "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 { session: currentSession } = getRequestSession(request);
const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession); 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({ const sessionId = createSession({
user: publicCmsUser(cmsUser), user: publicCmsUser(nextCmsUser),
github: { github: {
accessToken: tokenPayload.access_token, accessToken: tokenPayload.access_token,
login: user.login, login: user.login,
@@ -272,6 +507,10 @@ function normalizeSiteRoot(siteRoot = "") {
return siteRoot.trim().replace(/^\/+|\/+$/g, ""); return siteRoot.trim().replace(/^\/+|\/+$/g, "");
} }
function getVitePressSiteRoot(siteRoot = "") {
return normalizeSiteRoot(siteRoot) || "docs";
}
function toRepoPath(siteRoot, path) { function toRepoPath(siteRoot, path) {
const normalizedRoot = normalizeSiteRoot(siteRoot); const normalizedRoot = normalizeSiteRoot(siteRoot);
return normalizedRoot ? `${normalizedRoot}/${path}` : path; return normalizedRoot ? `${normalizedRoot}/${path}` : path;
@@ -290,6 +529,177 @@ function encodeBase64Content(content) {
return Buffer.from(content, "utf-8").toString("base64"); 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) { function parseFrontmatter(source) {
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); 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 owner = url.searchParams.get("owner");
const repo = url.searchParams.get("repo"); const repo = url.searchParams.get("repo");
const branch = url.searchParams.get("branch") || "main"; const branch = url.searchParams.get("branch") || "main";
const siteRoot = url.searchParams.get("siteRoot") || ""; const siteRoot = getVitePressSiteRoot(url.searchParams.get("siteRoot") || "");
if (!owner || !repo) { if (!owner || !repo) {
throw new Error("owner and repo are required"); 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) { async function handleSitePages(request, response, url) {
const session = requireSession(request, response); const session = requireSession(request, response);
if (!session) return; if (!session) return;
@@ -557,6 +1114,11 @@ export async function handleGithubApi(request, response, url) {
return true; 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") { if (url.pathname === "/api/github/site/pages") {
await handleSitePages(request, response, url); await handleSitePages(request, response, url);
return true; return true;
@@ -569,7 +1131,7 @@ export async function handleGithubApi(request, response, url) {
return false; return false;
} catch (error) { } catch (error) {
json(response, 500, { json(response, error?.statusCode || 500, {
error: error instanceof Error ? error.message : "Unknown GitHub API error", error: error instanceof Error ? error.message : "Unknown GitHub API error",
}); });
return true; return true;
+10
View File
@@ -6,6 +6,8 @@ import { bootstrap } from "./bootstrap.mjs";
import { handleAuthApi } from "./auth.mjs"; import { handleAuthApi } from "./auth.mjs";
import { handleGithubApi } from "./github.mjs"; import { handleGithubApi } from "./github.mjs";
import { handleSettingsApi } from "./settings.mjs"; import { handleSettingsApi } from "./settings.mjs";
import { handleUsersApi } from "./users.mjs";
import { handleProjectsApi } from "./projects.mjs";
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
const port = Number(process.env.PORT || 5173); const port = Number(process.env.PORT || 5173);
@@ -64,6 +66,14 @@ const server = createServer(async (request, response) => {
return; return;
} }
if (await handleUsersApi(request, response, url)) {
return;
}
if (await handleProjectsApi(request, response, url)) {
return;
}
if (await handleGithubApi(request, response, url)) { if (await handleGithubApi(request, response, url)) {
return; return;
} }
+31
View File
@@ -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] ?? [];
}
+343
View File
@@ -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
View File
@@ -1,18 +1,64 @@
import net from "node:net";
import tls from "node:tls";
import { db, setSetting } from "./db.mjs"; import { db, setSetting } from "./db.mjs";
import { requireCmsUser } from "./auth.mjs"; import { requirePermission } from "./auth.mjs";
import { json, readBody } from "./http.mjs"; import { json, readBody } from "./http.mjs";
import { permissions } from "./permissions.mjs";
function requireAdmin(request, response) { function getSettingValue(key, fallback = "") {
const user = requireCmsUser(request, response); 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") { const finish = (result) => {
json(response, 403, { error: "System admin permission is required" }); socket.removeAllListeners();
return undefined; 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) { function publicSetting(row) {
@@ -25,7 +71,7 @@ function publicSetting(row) {
} }
async function handleGetSettings(request, response) { async function handleGetSettings(request, response) {
if (!requireAdmin(request, response)) return; if (!requirePermission(request, response, permissions.settingsManage)) return;
const rows = db const rows = db
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key") .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) { 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 body = JSON.parse(await readBody(request) || "{}");
const settings = Array.isArray(body.settings) ? body.settings : []; const settings = Array.isArray(body.settings) ? body.settings : [];
settings.forEach((setting) => { settings.forEach((setting) => {
if (!setting.key) return; 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)); setSetting(String(setting.key), String(setting.value ?? ""), Boolean(setting.encrypted));
}); });
json(response, 200, { ok: true }); 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) { export async function handleSettingsApi(request, response, url) {
try { try {
if (url.pathname === "/api/admin/settings" && request.method === "GET") { if (url.pathname === "/api/admin/settings" && request.method === "GET") {
@@ -60,6 +125,11 @@ export async function handleSettingsApi(request, response, url) {
return true; return true;
} }
if (url.pathname === "/api/admin/settings/test-smtp" && request.method === "POST") {
await handleTestSmtp(request, response);
return true;
}
return false; return false;
} catch (error) { } catch (error) {
json(response, 500, { json(response, 500, {
+143
View File
@@ -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
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; 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 { import {
createGitHubRepository, createGitHubRepository,
loadGitHubRepositories, loadGitHubRepositories,
@@ -9,6 +10,7 @@ import {
startGitHubLogin, startGitHubLogin,
} from "./api/githubAuth"; } from "./api/githubAuth";
import { import {
initializeGitHubSite,
loadGitHubPages, loadGitHubPages,
loadGitHubSettings, loadGitHubSettings,
saveGitHubPage, saveGitHubPage,
@@ -27,18 +29,57 @@ import ConnectPanel from "./components/ConnectPanel.vue";
import ContentPanel from "./components/ContentPanel.vue"; import ContentPanel from "./components/ContentPanel.vue";
import LoginView from "./components/LoginView.vue"; import LoginView from "./components/LoginView.vue";
import PublishPanel from "./components/PublishPanel.vue"; import PublishPanel from "./components/PublishPanel.vue";
import ProjectPanel from "./components/ProjectPanel.vue";
import StructurePanel from "./components/StructurePanel.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 { 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 }> = [ const panels = computed<Array<{ key: PanelKey; label: string; icon: string }>>(() => {
{ key: "account", label: "用户中心", icon: "U" }, const basePanels: Array<{ key: PanelKey; label: string; icon: string }> = [
{ key: "connect", label: "仓库连接", icon: "G" }, { key: "account", label: "用户中心", icon: "U" },
{ key: "content", label: "页面管理", icon: "P" }, { key: "projects", label: "项目管理", icon: "J" },
{ key: "structure", label: "站点结构", icon: "S" }, { key: "connect", label: "仓库连接", icon: "G" },
{ key: "config", label: "主题配置", icon: "C" }, { key: "content", label: "页面管理", icon: "P" },
{ key: "publish", label: "发布中心", icon: "R" }, { 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>({ const githubConnection = reactive<GitHubConnection>({
token: "", token: "",
@@ -60,26 +101,59 @@ const sidebarGroups = reactive(
})), })),
); );
const siteSettings = reactive({ ...mockSiteSettings }); const siteSettings = reactive({ ...mockSiteSettings });
const systemSettings = ref<SystemSetting[]>([]);
const activePageId = ref(""); const activePageId = ref("");
const settingsSha = ref<string>(); const settingsSha = ref<string>();
const isLoadingPages = ref(false); const isLoadingPages = ref(false);
const isConnecting = ref(false); const isConnecting = ref(false);
const isLoadingRepos = ref(false); const isLoadingRepos = ref(false);
const isCreatingRepository = ref(false);
const isInitializingSite = ref(false);
const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle"); const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const settingsSaveState = 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 githubUser = ref<GitHubUser | null>(null);
const currentUser = ref<CmsUser | 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 repositories = ref<GitHubRepository[]>([]);
const isAuthLoading = ref(true); const isAuthLoading = ref(true);
const loginError = ref(""); 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 activityItems = ref(["等待读取站点"]);
const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]); 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(() => { const repoLabel = computed(() => {
if (dataSource.value === "github" && hasGitHubConnection.value) { if (dataSource.value === "github" && hasGitHubConnection.value) {
return `${githubConnection.owner}/${githubConnection.repo}`; return `${githubConnection.owner}/${githubConnection.repo}`;
} }
return "test-vitepress"; return "test-vitepress";
}); });
const repoBranch = computed(() => const repoBranch = computed(() =>
@@ -95,7 +169,7 @@ function normalizeConnection(connection: GitHubConnection): GitHubConnection {
owner: connection.owner.trim(), owner: connection.owner.trim(),
repo: connection.repo.trim(), repo: connection.repo.trim(),
branch: connection.branch.trim() || "main", 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() { async function reloadSite() {
await Promise.all([loadPages(), loadSettings()]); await Promise.all([loadPages(), loadSettings()]);
} }
@@ -187,6 +458,7 @@ async function refreshRepositories() {
async function loadAuthState() { async function loadAuthState() {
try { try {
authOptions.value = await loadAuthOptions();
currentUser.value = await loadCurrentUser(); currentUser.value = await loadCurrentUser();
githubUser.value = await loadGitHubUser(); githubUser.value = await loadGitHubUser();
@@ -208,7 +480,7 @@ async function handleEmailLogin(payload: { email: string; password: string }) {
try { try {
currentUser.value = await loginWithEmail(payload.email, payload.password); currentUser.value = await loginWithEmail(payload.email, payload.password);
await loadAuthState(); await loadAuthState();
await reloadSite(); await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
} catch (error) { } catch (error) {
loginError.value = error instanceof Error ? error.message : "登录失败"; loginError.value = error instanceof Error ? error.message : "登录失败";
} finally { } 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() { async function handleLogout() {
await logoutCms(); await logoutCms();
currentUser.value = null; currentUser.value = null;
githubUser.value = null; githubUser.value = null;
repositories.value = []; 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) { async function connectGithub(connection: GitHubConnection) {
const nextConnection = normalizeConnection(connection); const nextConnection = normalizeConnection(connection);
connectionMessage.value = "";
try { try {
validateConnection(nextConnection); validateConnection(nextConnection);
} catch (error) { } catch (error) {
connectionMessage.value = error instanceof Error ? error.message : "连接信息不完整";
activityItems.value = [ activityItems.value = [
error instanceof Error ? error.message : "连接信息不完整", error instanceof Error ? error.message : "连接信息不完整",
...activityItems.value, ...activityItems.value,
@@ -237,6 +557,7 @@ async function connectGithub(connection: GitHubConnection) {
} }
isConnecting.value = true; isConnecting.value = true;
connectionMessage.value = `正在读取 GitHub 仓库 ${nextConnection.owner}/${nextConnection.repo}`;
try { try {
const [nextPages, nextSettings] = await Promise.all([ const [nextPages, nextSettings] = await Promise.all([
@@ -251,10 +572,28 @@ async function connectGithub(connection: GitHubConnection) {
Object.assign(siteSettings, nextSettings.settings); Object.assign(siteSettings, nextSettings.settings);
settingsSha.value = nextSettings.sha; settingsSha.value = nextSettings.sha;
activePanel.value = "content"; 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]; activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value];
} catch (error) { } catch (error) {
hasGitHubConnection.value = false; hasGitHubConnection.value = false;
dataSource.value = "local"; 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 = [ activityItems.value = [
"GitHub 仓库连接失败,仍保持本地测试站点模式", "GitHub 仓库连接失败,仍保持本地测试站点模式",
error instanceof Error ? error.message : "未知错误", error instanceof Error ? error.message : "未知错误",
@@ -281,11 +620,24 @@ async function signOutGithub() {
} }
async function createRepository(payload: { name: string; private: boolean }) { async function createRepository(payload: { name: string; private: boolean }) {
isCreatingRepository.value = true;
repositoryMessage.value = `正在创建 GitHub 仓库 ${payload.name}`;
try { try {
const repo = await createGitHubRepository(payload); const repo = await createGitHubRepository(payload);
repositories.value = [repo, ...repositories.value]; 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]; activityItems.value = [`已创建 GitHub 仓库 ${repo.fullName}`, ...activityItems.value];
} catch (error) { } catch (error) {
repositoryMessage.value = error instanceof Error ? error.message : "创建 GitHub 仓库失败";
isCreatingRepository.value = false;
activityItems.value = [ activityItems.value = [
error instanceof Error ? error.message : "创建 GitHub 仓库失败", error instanceof Error ? error.message : "创建 GitHub 仓库失败",
...activityItems.value, ...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) { function selectPage(pageId: string) {
activePageId.value = pageId; activePageId.value = pageId;
pageSaveState.value = "idle"; 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() { function publishChanges() {
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value]; activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
} }
@@ -366,7 +784,7 @@ onMounted(async () => {
await loadAuthState(); await loadAuthState();
if (currentUser.value) { if (currentUser.value) {
await reloadSite(); await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
} }
} finally { } finally {
isAuthLoading.value = false; isAuthLoading.value = false;
@@ -377,9 +795,11 @@ onMounted(async () => {
<template> <template>
<LoginView <LoginView
v-if="!currentUser" v-if="!currentUser"
:auth-options="authOptions"
:error-message="loginError" :error-message="loginError"
:is-loading="isAuthLoading" :is-loading="isAuthLoading"
@email-login="handleEmailLogin" @email-login="handleEmailLogin"
@email-register="handleEmailRegister"
@github-login="startGitHubLogin" @github-login="startGitHubLogin"
/> />
@@ -411,23 +831,68 @@ onMounted(async () => {
<AccountPanel <AccountPanel
v-if="activePanel === 'account'" v-if="activePanel === 'account'"
:message="accountMessage"
:current-user="currentUser" :current-user="currentUser"
:github-user="githubUser" :github-user="githubUser"
:password-state="passwordSaveState"
:profile-state="profileSaveState"
@change-password="updatePassword"
@github-login="startGitHubLogin" @github-login="startGitHubLogin"
@github-logout="signOutGithub" @github-logout="signOutGithub"
@logout="handleLogout" @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 <ConnectPanel
v-else-if="activePanel === 'connect'" v-else-if="activePanel === 'connect'"
:connection="githubConnection" :connection="githubConnection"
:connection-message="connectionMessage"
:data-source="dataSource" :data-source="dataSource"
:github-user="githubUser" :github-user="githubUser"
:has-git-hub-connection="hasGitHubConnection" :has-git-hub-connection="hasGitHubConnection"
:is-connecting="isConnecting" :is-connecting="isConnecting"
:is-creating-repository="isCreatingRepository"
:is-initializing-site="isInitializingSite"
:is-loading-repos="isLoadingRepos" :is-loading-repos="isLoadingRepos"
:repository-message="repositoryMessage"
:repositories="repositories" :repositories="repositories"
@connect-github="connectGithub" @connect-github="connectGithub"
@create-repository="createRepository" @create-repository="createRepository"
@initialize-repository="initializeRepository"
@load-repositories="refreshRepositories" @load-repositories="refreshRepositories"
@login-github="startGitHubLogin" @login-github="startGitHubLogin"
@logout-github="signOutGithub" @logout-github="signOutGithub"
+50
View File
@@ -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;
};
}
+70
View File
@@ -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
View File
@@ -1,24 +1,44 @@
import type { CmsUser } from "../types"; import type { AuthOptions, CmsUser } from "../types";
interface MeResponse { interface MeResponse {
user: CmsUser | null; user: CmsUser | null;
} }
interface LoginResponse { interface UserResponse {
user: CmsUser; 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() { export async function loadCurrentUser() {
const response = await fetch("/api/auth/me"); const response = await fetch("/api/auth/me");
if (!response.ok) { if (!response.ok) {
throw new Error(`读取当前用户失败:${response.status}`); throw new Error(await readError(response, "读取当前用户失败"));
} }
const data = (await response.json()) as MeResponse; const data = (await response.json()) as MeResponse;
return data.user; 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) { export async function loginWithEmail(email: string, password: string) {
const response = await fetch("/api/auth/login", { const response = await fetch("/api/auth/login", {
method: "POST", method: "POST",
@@ -29,19 +49,70 @@ export async function loginWithEmail(email: string, password: string) {
}); });
if (!response.ok) { 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; 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() { export async function logoutCms() {
const response = await fetch("/api/auth/logout", { const response = await fetch("/api/auth/logout", {
method: "POST", method: "POST",
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`退出登录失败:${response.status}`); throw new Error(await readError(response, "退出登录失败"));
} }
} }
+2 -1
View File
@@ -62,7 +62,8 @@ export async function createGitHubRepository(payload: {
}); });
if (!response.ok) { 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; const data = (await response.json()) as CreateRepoResponse;
+44 -7
View File
@@ -1,6 +1,15 @@
import type { DocPage, GitHubConnection, SiteSettings } from "../types"; import type { DocPage, GitHubConnection, SiteSettings } from "../types";
import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent"; 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 { interface GitTreeResponse {
tree: Array<{ tree: Array<{
path: string; path: string;
@@ -38,7 +47,7 @@ function siteParams(connection: GitHubConnection) {
owner: connection.owner, owner: connection.owner,
repo: connection.repo, repo: connection.repo,
branch: connection.branch, branch: connection.branch,
siteRoot: connection.siteRoot, siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
}); });
} }
@@ -71,6 +80,11 @@ function encodeBase64Content(content: string) {
return btoa(binary); 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) { async function githubFetch<T>(connection: GitHubConnection, path: string, init?: RequestInit) {
const response = await fetch(`https://api.github.com${path}`, { const response = await fetch(`https://api.github.com${path}`, {
...init, ...init,
@@ -101,7 +115,7 @@ export async function loadGitHubPages(connection: GitHubConnection) {
const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`); const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`读取 GitHub 页面失败${response.status}`); throw new Error(await readApiError(response, "读取 GitHub 页面失败"));
} }
const data = (await response.json()) as { pages: DocPage[] }; const data = (await response.json()) as { pages: DocPage[] };
@@ -152,14 +166,14 @@ export async function saveGitHubPage(connection: GitHubConnection, page: DocPage
owner: connection.owner, owner: connection.owner,
repo: connection.repo, repo: connection.repo,
branch: connection.branch, branch: connection.branch,
siteRoot: connection.siteRoot, siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
}, },
page, page,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`保存 GitHub 页面失败${response.status}`); throw new Error(await readApiError(response, "保存 GitHub 页面失败"));
} }
const data = (await response.json()) as { sha: string }; 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()}`); const response = await fetch(`/api/github/site/settings?${siteParams(connection).toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`读取 GitHub 配置失败${response.status}`); throw new Error(await readApiError(response, "读取 GitHub 配置失败"));
} }
return (await response.json()) as { 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( export async function saveGitHubSettings(
connection: GitHubConnection, connection: GitHubConnection,
settings: SiteSettings, settings: SiteSettings,
@@ -228,7 +265,7 @@ export async function saveGitHubSettings(
owner: connection.owner, owner: connection.owner,
repo: connection.repo, repo: connection.repo,
branch: connection.branch, branch: connection.branch,
siteRoot: connection.siteRoot, siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
}, },
settings, settings,
sha, sha,
@@ -236,7 +273,7 @@ export async function saveGitHubSettings(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`保存 GitHub 配置失败${response.status}`); throw new Error(await readApiError(response, "保存 GitHub 配置失败"));
} }
const data = (await response.json()) as { sha: string }; const data = (await response.json()) as { sha: string };
+136
View File
@@ -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
View File
@@ -1,40 +1,144 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch } from "vue";
import type { CmsUser, GitHubUser } from "../types"; import type { CmsUser, GitHubUser } from "../types";
defineProps<{ const props = defineProps<{
currentUser: CmsUser; currentUser: CmsUser;
githubUser: GitHubUser | null; githubUser: GitHubUser | null;
profileState: "idle" | "saving" | "saved" | "error";
passwordState: "idle" | "saving" | "saved" | "error";
message: string;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
githubLogin: []; githubLogin: [];
githubLogout: []; githubLogout: [];
logout: []; 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> </script>
<template> <template>
<section class="panel is-visible"> <section class="panel is-visible">
<div class="account-layout"> <div class="account-grid">
<section class="management-card"> <section class="management-card">
<p class="eyebrow">account</p> <p class="eyebrow">profile</p>
<h3>用户中心</h3> <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"> <dl class="source-summary account-summary">
<div>
<dt>名称</dt>
<dd>{{ currentUser.name }}</dd>
</div>
<div>
<dt>邮箱</dt>
<dd>{{ currentUser.email || "-" }}</dd>
</div>
<div> <div>
<dt>角色</dt> <dt>角色</dt>
<dd>{{ currentUser.role }}</dd> <dd>{{ currentUser.role }}</dd>
</div> </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> </dl>
<button class="ghost-button account-logout" type="button" @click="$emit('logout')"> </section>
退出 CMS 登录
<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> </button>
</section> </section>
@@ -51,9 +155,7 @@ defineEmits<{
</div> </div>
<div v-else> <div v-else>
<p class="helper-text">绑定 GitHub 可以读取仓库列表选择站点仓库并提交内容变更</p> <p class="helper-text">绑定 GitHub 可以读取仓库列表选择站点仓库并提交内容变更</p>
<button class="primary-button" type="button" @click="$emit('githubLogin')"> <button class="primary-button" type="button" @click="$emit('githubLogin')">绑定 GitHub</button>
绑定 GitHub
</button>
</div> </div>
</section> </section>
</div> </div>
+24 -4
View File
@@ -4,17 +4,22 @@ import type { DataSource, GitHubConnection, GitHubRepository, GitHubUser } from
const props = defineProps<{ const props = defineProps<{
connection: GitHubConnection; connection: GitHubConnection;
connectionMessage: string;
dataSource: DataSource; dataSource: DataSource;
githubUser: GitHubUser | null; githubUser: GitHubUser | null;
hasGitHubConnection: boolean; hasGitHubConnection: boolean;
isConnecting: boolean; isConnecting: boolean;
isCreatingRepository: boolean;
isInitializingSite: boolean;
isLoadingRepos: boolean; isLoadingRepos: boolean;
repositoryMessage: string;
repositories: GitHubRepository[]; repositories: GitHubRepository[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
connectGithub: [connection: GitHubConnection]; connectGithub: [connection: GitHubConnection];
createRepository: [payload: { name: string; private: boolean }]; createRepository: [payload: { name: string; private: boolean }];
initializeRepository: [connection: GitHubConnection];
loginGithub: []; loginGithub: [];
loadRepositories: []; loadRepositories: [];
logoutGithub: []; logoutGithub: [];
@@ -64,6 +69,11 @@ function createRepository() {
private: newRepo.private, private: newRepo.private,
}); });
} }
function initializeRepository() {
if (!canConnect.value) return;
emit("initializeRepository", { ...draft });
}
</script> </script>
<template> <template>
@@ -113,7 +123,7 @@ function createRepository() {
</label> </label>
<label> <label>
站点目录 站点目录
<input v-model="draft.siteRoot" placeholder="docs 或留空" /> <input v-model="draft.siteRoot" placeholder="默认 docs" />
</label> </label>
</div> </div>
@@ -121,7 +131,16 @@ function createRepository() {
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub"> <button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
{{ isConnecting ? "连接中" : "读取选中仓库" }} {{ isConnecting ? "连接中" : "读取选中仓库" }}
</button> </button>
<button
class="secondary-button"
type="button"
:disabled="!canConnect || isInitializingSite"
@click="initializeRepository"
>
{{ isInitializingSite ? "初始化中" : "初始化 VitePress 模板" }}
</button>
</div> </div>
<p v-if="connectionMessage" class="helper-text">{{ connectionMessage }}</p>
<div class="new-repo-box"> <div class="new-repo-box">
<p class="eyebrow">create</p> <p class="eyebrow">create</p>
@@ -136,8 +155,9 @@ function createRepository() {
<input v-model="newRepo.private" type="checkbox" /> <input v-model="newRepo.private" type="checkbox" />
</label> </label>
</div> </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> </button>
</div> </div>
</template> </template>
@@ -165,7 +185,7 @@ function createRepository() {
</label> </label>
<label class="wide-field"> <label class="wide-field">
站点目录 站点目录
<input v-model="draft.siteRoot" placeholder="docs 或留空" /> <input v-model="draft.siteRoot" placeholder="默认 docs" />
</label> </label>
</div> </div>
+46 -6
View File
@@ -1,23 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from "vue"; import { reactive } from "vue";
import type { AuthOptions } from "../types";
defineProps<{ const props = defineProps<{
authOptions: AuthOptions;
errorMessage: string; errorMessage: string;
isLoading: boolean; isLoading: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
emailLogin: [payload: { email: string; password: string }]; emailLogin: [payload: { email: string; password: string }];
emailRegister: [payload: { email: string; name: string; password: string }];
githubLogin: []; githubLogin: [];
}>(); }>();
const mode = reactive({
value: "login" as "login" | "register",
});
const form = reactive({ const form = reactive({
email: "admin@example.com", email: "admin@example.com",
name: "",
password: "admin123456", password: "admin123456",
}); });
function submit() { 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> </script>
@@ -32,27 +54,45 @@ function submit() {
</div> </div>
</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> <label>
邮箱 邮箱
<input v-model="form.email" type="email" autocomplete="username" /> <input v-model="form.email" type="email" autocomplete="username" />
</label> </label>
<label v-if="mode.value === 'register' || (!authOptions.allowEmailLogin && authOptions.allowRegistration)">
昵称
<input v-model="form.name" autocomplete="name" />
</label>
<label> <label>
密码 密码
<input v-model="form.password" type="password" autocomplete="current-password" /> <input v-model="form.password" type="password" autocomplete="current-password" />
</label> </label>
<button class="primary-button" type="submit" :disabled="isLoading"> <button class="primary-button" type="submit" :disabled="isLoading">
{{ isLoading ? "登录中" : "邮箱登录" }} {{ isLoading ? "处理中" : mode.value === "register" || (!authOptions.allowEmailLogin && authOptions.allowRegistration) ? "创建账号" : "邮箱登录" }}
</button> </button>
</form> </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> <span>GitHub</span>
<strong>使用 GitHub 登录</strong> <strong>使用 GitHub 登录</strong>
</button> </button>
<p v-if="errorMessage" class="save-status is-error">{{ errorMessage }}</p> <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> </section>
</main> </main>
</template> </template>
+199
View File
@@ -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>
+143
View File
@@ -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>
+150
View File
@@ -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
View File
@@ -498,6 +498,16 @@ label {
font-weight: 700; 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 { .empty-state {
color: var(--muted); color: var(--muted);
} }
@@ -543,6 +553,8 @@ label {
.two-column, .two-column,
.publish-layout, .publish-layout,
.account-layout, .account-layout,
.account-grid,
.project-layout,
.connect-layout { .connect-layout {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -563,6 +575,10 @@ label {
margin-top: 14px; margin-top: 14px;
} }
.single-column {
grid-template-columns: 1fr;
}
.wide-field { .wide-field {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -614,10 +630,163 @@ label {
margin-bottom: 16px; 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 { .account-logout {
width: 100%; 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 { .source-summary {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -747,12 +916,23 @@ label {
.two-column, .two-column,
.publish-layout, .publish-layout,
.account-layout, .account-layout,
.account-grid,
.project-layout,
.connect-layout, .connect-layout,
.settings-grid, .settings-grid,
.frontmatter-grid, .frontmatter-grid,
.form-grid { .form-grid,
.user-create-grid,
.user-table-head,
.user-table-row,
.member-editor,
.member-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.user-table-head {
display: none;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
+45 -2
View File
@@ -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 EditorMode = "write" | "preview";
export type DataSource = "local" | "github"; export type DataSource = "local" | "github";
@@ -57,7 +57,19 @@ export interface CmsUser {
email?: string; email?: string;
name: string; name: string;
avatarUrl?: 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 { export interface GitHubRepository {
@@ -69,3 +81,34 @@ export interface GitHubRepository {
defaultBranch: string; defaultBranch: string;
updatedAt: 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;
}
+31
View File
@@ -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));
}