Files
plainleaf/client/spaces_ui/components/UsersView.tsx
T
wushenghua 9e43ae824a
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
feat: complete task planning and account management
Add Markdown-native planning, repeat rules, subtasks, reminders, transactional backup restore, Chinese account management, and the account-managed NAS migration configuration.\n\nCo-Authored-By: Codex <noreply@anthropic.com>
2026-08-12 20:22:33 +08:00

373 lines
11 KiB
TypeScript

import { useEffect, useState } from "preact/hooks";
import {
Alert,
Badge,
Button,
Checkbox,
Input,
} from "@silverbulletmd/silverbullet/ui";
import {
createToken,
createUser,
deleteToken,
deleteUser,
formatApiError,
getUser,
listUsers,
setUserAdmin,
setUserPassword,
} from "../api.ts";
import { useNavigate } from "../navigation.ts";
import { spacesUrl } from "../routes.ts";
import type { UserInfo } from "../types.ts";
function useUserList(onUnauthorized: () => void) {
const [users, setUsers] = useState<Record<string, UserInfo>>({});
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
listUsers()
.then((users) => {
setUsers(users);
setLoaded(true);
})
.catch((error: any) => {
if (error.unauthorized) onUnauthorized();
else setError(formatApiError(error));
setLoaded(true);
});
}, []);
return { users, loaded, error };
}
export function UserList({
currentUsername,
onUnauthorized,
}: {
currentUsername: string;
onUnauthorized: () => void;
}) {
const { users, loaded, error } = useUserList(onUnauthorized);
return (
<div>
{/* No heading: this screen is only ever reached from the tab bar, which
already names it. See SpaceList for the non-admin case. */}
{error && <Alert variant="error">{error}</Alert>}
{!loaded && <p>正在加载…</p>}
{loaded && Object.keys(users).length === 0 && <p>还没有用户。</p>}
{loaded && Object.keys(users).length > 0 && (
<table class="sb-user-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
{/* Actions column; the header stays empty. */}
<th></th>
</tr>
</thead>
<tbody>
{Object.entries(users)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, user]) => {
const href = spacesUrl(`/users/${encodeURIComponent(name)}`);
return (
<tr key={name}>
<td>
<a class="sb-user-link" href={href}>
{name}
</a>{" "}
{name === currentUsername && <Badge>当前账号</Badge>}
</td>
<td>{user.admin ? "管理员" : "普通用户"}</td>
<td>
{/* Same destination as the name — an explicit control
for anyone who doesn't read the name as clickable,
mirroring the spaces list. */}
<a class="sb-button sb-user-edit" href={href}>
管理
</a>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{loaded && (
<div class="row">
<a class="sb-button sb-button-primary" href={spacesUrl("/users/new")}>
新建用户
</a>
</div>
)}
</div>
);
}
export function NewUser({ onUnauthorized }: { onUnauthorized: () => void }) {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [admin, setAdmin] = useState(false);
const [error, setError] = useState("");
return (
<form
onSubmit={(event) => {
event.preventDefault();
createUser(username, password, admin)
.then(() =>
navigate(
spacesUrl(`/users/${encodeURIComponent(username.trim())}`),
),
)
.catch((error: any) => {
if (error.unauthorized) onUnauthorized();
else setError(formatApiError(error));
});
}}
>
<h1>新建用户</h1>
{error && <Alert variant="error">{error}</Alert>}
<label for="new-user-username">用户名</label>
<Input
id="new-user-username"
value={username}
onInput={(event) => setUsername(event.currentTarget.value)}
/>
<label for="new-user-password">初始密码</label>
<Input
id="new-user-password"
type="password"
value={password}
onInput={(event) => setPassword(event.currentTarget.value)}
/>
<label>
<Checkbox
checked={admin}
onChange={(event) => setAdmin(event.currentTarget.checked)}
/>{" "}
设为管理员
</label>
<div class="row">
<Button type="submit" variant="primary">
创建用户
</Button>
<a class="sb-button" href={spacesUrl("/users")}>
取消
</a>
</div>
</form>
);
}
export function UserDetail({
username,
currentUsername,
onUnauthorized,
}: {
username: string;
currentUsername: string;
onUnauthorized: () => void;
}) {
const navigate = useNavigate();
const [user, setUser] = useState<UserInfo | undefined>();
const [loaded, setLoaded] = useState(false);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState("");
const [password, setPassword] = useState("");
const [tokenName, setTokenName] = useState("");
const [shownToken, setShownToken] = useState<string | undefined>();
const isSelf = username === currentUsername;
async function reload() {
try {
setUser(await getUser(username));
setError("");
} catch (error: any) {
if (error.unauthorized) onUnauthorized();
else if (error.notFound) setNotFound(true);
else setError(formatApiError(error));
} finally {
setLoaded(true);
}
}
useEffect(() => {
void reload();
}, [username]);
async function run(action: () => Promise<void>) {
try {
await action();
setError("");
} catch (error: any) {
if (error.unauthorized) onUnauthorized();
else setError(formatApiError(error));
}
}
if (!loaded) return <p>正在加载…</p>;
if (notFound) {
return (
<div>
<h1>找不到用户</h1>
<p>
<a href={spacesUrl("/users")}>返回用户列表</a>
</p>
</div>
);
}
if (!user) return <Alert variant="error">{error || "找不到用户"}</Alert>;
const tokenNames = Object.keys(user.tokens);
return (
<div>
<h1>
{username} {isSelf && <Badge>当前账号</Badge>}
</h1>
{error && <Alert variant="error">{error}</Alert>}
<section>
<h2>角色与权限</h2>
<label>
<Checkbox
checked={user.admin}
onChange={(event) => {
const admin = event.currentTarget.checked;
if (
isSelf &&
!admin &&
!confirm(
`确定取消当前账号“${username}”的管理员权限吗?当前会话将立即失去管理能力。`,
)
) {
event.currentTarget.checked = true;
return;
}
void run(async () => {
await setUserAdmin(username, admin);
if (isSelf && !admin) location.assign("/");
else await reload();
});
}}
/>{" "}
管理员
</label>
</section>
<section>
<h2>修改密码</h2>
<div class="row">
<Input
type="password"
aria-label="新密码"
placeholder="输入新密码"
value={password}
onInput={(event) => setPassword(event.currentTarget.value)}
/>
<Button
variant="primary"
onClick={() =>
void run(async () => {
await setUserPassword(username, password);
setPassword("");
if (isSelf) location.assign(loginUrlForUser(username));
})
}
>
更新密码
</Button>
</div>
</section>
<section>
<h2>API 令牌</h2>
<p class="sb-help-text">
用于外部工具访问。令牌拥有该用户的权限,请妥善保管。
</p>
{tokenNames.length === 0 && <p>还没有令牌。</p>}
{tokenNames.length > 0 && (
<ul class="sb-token-list">
{tokenNames.map((name) => (
<li key={name}>
<strong>{name}</strong>
<span>
创建于{" "}
{new Date(user.tokens[name].createdAt).toLocaleString()}
</span>
<Button
onClick={() => {
if (!confirm(`确定撤销“${username}”的令牌“${name}”吗?`)) {
return;
}
void run(async () => {
await deleteToken(username, name);
await reload();
});
}}
>
撤销
</Button>
</li>
))}
</ul>
)}
<div class="row">
<Input
aria-label="令牌名称"
placeholder="令牌名称,例如:自动备份"
value={tokenName}
onInput={(event) => setTokenName(event.currentTarget.value)}
/>
<Button
variant="primary"
onClick={() => {
const name = tokenName.trim();
if (!name) return;
void run(async () => {
setShownToken(await createToken(username, name));
setTokenName("");
await reload();
});
}}
>
创建令牌
</Button>
</div>
{shownToken && (
<div class="sb-token-reveal">
<Alert variant="warning">此令牌只显示一次,请立即妥善保存。</Alert>
<Input
readOnly
value={shownToken}
onClick={(event) => event.currentTarget.select()}
/>
<Button onClick={() => setShownToken(undefined)}>我已保存</Button>
</div>
)}
</section>
<div class="sb-danger-zone">
<Button
variant="danger"
onClick={() => {
const message = isSelf
? `确定删除当前账号“${username}”吗?你会立即退出登录。`
: `确定删除用户“${username}”吗?`;
if (!confirm(message)) return;
void run(async () => {
await deleteUser(username);
// Deleting your own account ends the session, so that one has
// to be a real navigation out of the app.
if (isSelf) location.assign("/");
else navigate(spacesUrl("/users"));
});
}}
>
删除用户
</Button>
</div>
</div>
);
}
function loginUrlForUser(username: string): string {
const next = spacesUrl(`/users/${encodeURIComponent(username)}`);
return `${spacesUrl("/login")}?next=${encodeURIComponent(next)}`;
}