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
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>
373 lines
11 KiB
TypeScript
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)}`;
|
|
}
|