feat: support isolated multi-user workspaces

This commit is contained in:
yoma committed 2026-07-15 17:58:30 +08:00
1 parent eabcdda651
commit b0d0686d2c
13 files changed
+993 -263

No files matched your search

+5 -1
View File
@@ -37,7 +37,7 @@ The public demo resets every Monday at 1:00 AM (China Standard Time) and restore
- Batch note merging.
- Batch note moving, notebook drag sorting, and hierarchy editing.
- Offline drafts and local sync queue for existing notes.
- Single-user login with PBKDF2-SHA256 password hashing.
- Multi-user instances with isolated personal workspaces, owner-managed accounts, and PBKDF2-SHA256 password hashing.
- Chrome/Edge web clipper is complete and pending store publication.
## Deployment
@@ -71,6 +71,10 @@ Please refer to the [Cloudflare Manual Deployment Guide](docs/manual-deploy.md)
The automated helper commands are recommended. The template uses `admin` / `admin123` for the initial login, and the password can be changed later in Personal Settings. If you create the Cloudflare resources manually, finish configuring `.env.local`—including the D1 ID, R2 bucket, and the 400-day session limit—before running `bun run deploy`. Existing installations that use `EDGE_EVER_AUTH_PASSWORD_HASH` remain supported. Use that command only for first installation and emergency recovery; Workers Builds handles routine updates.
## Multi-user Instances
The instance owner can create, disable, and reset member accounts in **Profile** -> **User accounts**. Public registration is disabled. Every member gets an isolated personal workspace, including notebooks, notes, attachments, Trash, imports/exports, and MCP tokens. Existing single-user installations are migrated automatically: all existing data remains in the original owner's default workspace. Cloudflare free-tier quotas are shared by the whole instance rather than allocated per user.
## PWA Installation
EdgeEver can be installed as a PWA on desktop or mobile home screens. On desktop, open the site in Chrome or Edge and use the install icon in the address bar. On Android, open it in Chrome, use the three-dot menu, and choose **Add to Home screen** or **Install**. Avoid installing from embedded browsers such as WeChat.
+5 -1
View File
@@ -39,7 +39,7 @@ EdgeEver 想填补这个空白:保留熟悉的笔记体验,同时提供开
- 多选合并笔记。
- 多选移动笔记,笔记本支持拖拽排序和调整层级。
- 已有笔记支持离线编辑草稿和本地同步队列。
- 单用户登录,密码使用 PBKDF2-SHA256 hash。
- 支持单实例多账号,每个账号拥有隔离的个人笔记空间;管理员可创建、停用和重置成员账号,密码使用 PBKDF2-SHA256 hash。
- Chrome/Edge 网页裁剪插件已开发完成,待上架。
## 部署
@@ -71,6 +71,10 @@ Agent 应优先按 [AI Agent Cloudflare Deployment](docs/agent-deploy-cloudflare
推荐优先使用自动化辅助命令。如果选择完全手动创建 Cloudflare 资源,必须先在 `.env.local` 中完成 D1 ID、R2 bucket、密码 hash 和 400 天会话期限等配置,再执行 `bun run deploy`。该命令仅用于首次安装和紧急修复;日常更新由 Workers Builds 自动完成。
## 多账号实例
实例管理员可以在 **个人中心** -> **账号管理** 中创建、停用成员账号或重置密码,实例默认不开放公开注册。每个成员拥有完全隔离的个人空间,包括笔记本、笔记、附件、回收站、导入导出和 MCP Token。已有单用户实例升级时会自动把全部原数据归入原管理员的默认空间,不改变原有数据 ID。Cloudflare 免费额度由整个实例共同使用,不会按账号分别增加。
## PWA 安装说明
PWA 可以把 EdgeEver 像普通应用一样安装到桌面或手机主屏幕,打开更方便,也能配合浏览器能力提供更接近原生 App 的使用体验。
+547 -260
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -12,6 +12,7 @@ import { PreferenceCard } from "./settings/PreferenceCard";
import { PasswordCard } from "./settings/PasswordCard";
import { SessionCard } from "./settings/SessionCard";
import { SystemInfoCard } from "./settings/SystemInfoCard";
import { UserManagementCard } from "./settings/UserManagementCard";
import { ThemeToggle } from "./ThemeToggle";
interface SettingsPaneProps {
@@ -23,6 +24,7 @@ interface SettingsPaneProps {
onLogout: () => void;
isLoggingOut: boolean;
authRequired: boolean;
isOwner: boolean;
onShowGuide?: () => void;
}
@@ -41,6 +43,7 @@ export const SettingsPane = ({
onLogout,
isLoggingOut,
authRequired,
isOwner,
onShowGuide,
}: SettingsPaneProps) => {
const { t } = useTranslation();
@@ -84,6 +87,7 @@ export const SettingsPane = ({
<McpConfigCard />
</SettingsGroup>
<SettingsGroup>
{isOwner ? <UserManagementCard /> : null}
<PasswordCard authRequired={authRequired} />
<SystemInfoCard />
<SessionCard authRequired={authRequired} isLoggingOut={isLoggingOut} onLogout={onLogout} />
+1
View File
@@ -2571,6 +2571,7 @@ export const WorkspaceApp = ({
onLogout={onLogout}
isLoggingOut={isLoggingOut}
authRequired={authRequired}
isOwner={authRequired && user?.role === "owner"}
onShowGuide={() => setRightView("evernote-migration")}
/>
) : rightView === "assets" ? (
@@ -0,0 +1,201 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, UserPlus, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { InstanceUser } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ApiRequestError, api } from "@/lib/api";
export const UserManagementCard = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [resetUser, setResetUser] = useState<InstanceUser | null>(null);
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [resetPassword, setResetPassword] = useState("");
const usersQuery = useQuery({ queryKey: ["users"], queryFn: api.listUsers });
const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
const createMutation = useMutation({
mutationFn: api.createUser,
onSuccess: () => {
setCreateOpen(false);
setUsername("");
setDisplayName("");
setPassword("");
void refresh();
},
});
const updateMutation = useMutation({
mutationFn: ({ userId, input }: { userId: string; input: { password?: string; isDisabled?: boolean } }) =>
api.updateUser(userId, input),
onSuccess: () => void refresh(),
});
const handleCreate = (event: FormEvent) => {
event.preventDefault();
createMutation.mutate({ username, displayName: displayName || null, password });
};
const handleCreateOpenChange = (open: boolean) => {
setCreateOpen(open);
if (!open) {
setUsername("");
setDisplayName("");
setPassword("");
createMutation.reset();
}
};
const createError =
createMutation.error instanceof ApiRequestError && createMutation.error.code === "username_exists"
? t("users.usernameExists")
: t("users.failed");
const handleReset = (event: FormEvent) => {
event.preventDefault();
if (!resetUser) return;
updateMutation.mutate(
{ userId: resetUser.id, input: { password: resetPassword } },
{ onSuccess: () => { setResetUser(null); setResetPassword(""); } },
);
};
return (
<>
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-emerald-700" />
{t("users.title")}
</CardTitle>
<CardDescription className="mt-1">{t("users.description")}</CardDescription>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<UserPlus className="h-4 w-4" /> {t("users.create")}
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-2 p-4 pt-0">
{usersQuery.isLoading ? <p className="text-sm text-slate-500">{t("users.loading")}</p> : null}
{usersQuery.data?.users.map((user) => (
<div key={user.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 p-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-800">{user.displayName || user.username}</p>
<p className="truncate text-xs text-slate-500">@{user.username} · {t(`users.roles.${user.role}`)}</p>
</div>
<div className="flex items-center gap-3">
<Button size="sm" variant="outline" onClick={() => setResetUser(user)}>
<KeyRound className="h-3.5 w-3.5" /> {t("users.resetPassword")}
</Button>
{user.role !== "owner" ? (
<label className="flex items-center gap-2 text-xs text-slate-600">
{user.isDisabled ? t("users.disabled") : t("users.enabled")}
<Switch
checked={!user.isDisabled}
disabled={updateMutation.isPending}
onCheckedChange={(checked) => updateMutation.mutate({ userId: user.id, input: { isDisabled: !checked } })}
/>
</label>
) : null}
</div>
</div>
))}
{usersQuery.isError || createMutation.isError || updateMutation.isError ? (
<p className="text-xs font-medium text-rose-600">{t("users.failed")}</p>
) : null}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={handleCreateOpenChange}>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-slate-100 px-6 py-5 pr-12">
<DialogTitle>{t("users.createTitle")}</DialogTitle>
<DialogDescription className="leading-6">{t("users.createDescription")}</DialogDescription>
</DialogHeader>
<form className="grid gap-5 px-6 py-5" autoComplete="off" onSubmit={handleCreate}>
<label className="grid gap-2 text-sm font-medium text-slate-700" htmlFor="edgeever-new-account-username">
{t("users.username")}
<Input
id="edgeever-new-account-username"
name="edgeever-new-account-username"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder={t("users.usernamePlaceholder")}
required
maxLength={80}
/>
</label>
<label className="grid gap-2 text-sm font-medium text-slate-700" htmlFor="edgeever-new-account-display-name">
{t("users.displayName")}
<Input
id="edgeever-new-account-display-name"
name="edgeever-new-account-display-name"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder={t("users.displayNamePlaceholder")}
maxLength={80}
/>
</label>
<label className="grid gap-2 text-sm font-medium text-slate-700" htmlFor="edgeever-new-account-password">
{t("users.password")}
<Input
id="edgeever-new-account-password"
name="edgeever-new-account-password"
type="password"
autoComplete="new-password"
data-1p-ignore
data-lpignore="true"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={t("users.passwordPlaceholder")}
minLength={8}
required
/>
<span className="text-xs font-normal text-slate-500">{t("users.passwordHint")}</span>
</label>
{createMutation.isError ? <p className="text-sm font-medium text-rose-600" role="alert">{createError}</p> : null}
<DialogFooter className="mt-1 gap-2 sm:space-x-0">
<DialogClose asChild><Button type="button" variant="outline">{t("common.cancel")}</Button></DialogClose>
<Button type="submit" variant="solid" disabled={createMutation.isPending}>
{createMutation.isPending ? t("users.creating") : t("users.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(resetUser)} onOpenChange={(open) => { if (!open) setResetUser(null); }}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>{t("users.resetTitle", { username: resetUser?.username })}</DialogTitle><DialogDescription>{t("users.resetDescription")}</DialogDescription></DialogHeader>
<form className="grid gap-4" autoComplete="off" onSubmit={handleReset}>
<label className="grid gap-2 text-sm font-medium text-slate-700" htmlFor="edgeever-reset-account-password">
{t("users.newPassword")}
<Input id="edgeever-reset-account-password" name="edgeever-reset-account-password" type="password" autoComplete="new-password" value={resetPassword} onChange={(event) => setResetPassword(event.target.value)} placeholder={t("users.passwordPlaceholder")} minLength={8} required />
</label>
<DialogFooter className="gap-2 sm:space-x-0">
<DialogClose asChild><Button type="button" variant="outline">{t("common.cancel")}</Button></DialogClose>
<Button type="submit" variant="solid" disabled={updateMutation.isPending}>{t("users.resetPassword")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
};
+25
View File
@@ -542,6 +542,31 @@ export const enUS = {
loggingOut: "Signing out...",
logout: "Sign out",
},
users: {
title: "User accounts",
description: "Create isolated personal note spaces for family or team members. Public registration stays disabled.",
loading: "Loading accounts...",
create: "Create account",
creating: "Creating...",
createTitle: "Create account",
createDescription: "The account gets isolated notes, attachments, Trash, and MCP tokens.",
username: "Username",
usernamePlaceholder: "For example: alex",
usernameExists: "That username already exists. Choose another one.",
displayName: "Display name",
displayNamePlaceholder: "Optional, for example: Alex",
password: "Initial password",
passwordPlaceholder: "Enter at least 8 characters",
passwordHint: "The member can change this password in Profile after signing in.",
newPassword: "New password (8+ characters)",
resetPassword: "Reset password",
resetTitle: "Reset password for {{username}}",
resetDescription: "Other sessions for this account will be revoked after the reset.",
enabled: "Enabled",
disabled: "Disabled",
failed: "The account operation failed. Check the input and try again.",
roles: { owner: "Instance owner", member: "Member" },
},
password: {
title: "Change password",
description: "Your current device stays signed in; other sessions will be signed out.",
+25
View File
@@ -542,6 +542,31 @@ export const zhCN = {
loggingOut: "安全退出中...",
logout: "退出登录",
},
users: {
title: "账号管理",
description: "为家人或团队成员创建独立的个人笔记空间。实例不开放公开注册。",
loading: "正在加载账号...",
create: "创建账号",
creating: "正在创建...",
createTitle: "创建新账号",
createDescription: "新账号会获得完全独立的笔记、附件、回收站和 MCP Token。",
username: "登录账号",
usernamePlaceholder: "例如:xiaoming",
usernameExists: "这个登录账号已经存在,请换一个名称。",
displayName: "显示名称",
displayNamePlaceholder: "选填,例如:小明",
password: "初始密码",
passwordPlaceholder: "请输入至少 8 位密码",
passwordHint: "成员首次登录后可以在个人中心修改密码。",
newPassword: "新密码(至少 8 位)",
resetPassword: "重置密码",
resetTitle: "重置 {{username}} 的密码",
resetDescription: "重置后,该账号在其他设备上的登录会话将失效。",
enabled: "已启用",
disabled: "已停用",
failed: "账号操作失败,请检查输入后重试。",
roles: { owner: "实例管理员", member: "成员" },
},
password: {
title: "修改密码",
description: "修改后会保留当前设备登录,并退出其他设备上的登录会话。",
+18
View File
@@ -1,5 +1,6 @@
import type {
AuthSession,
InstanceUser,
ApiToken,
CreatedApiToken,
JsonBackupMemo,
@@ -46,6 +47,9 @@ type ListApiTokensResponse = {
availableScopes: string[];
};
type ListUsersResponse = { users: InstanceUser[] };
type UserResponse = { user: InstanceUser };
type MemoResponse = {
memo: MemoDetail;
};
@@ -127,6 +131,20 @@ export const api = {
body: JSON.stringify(payload),
}),
listUsers: () => request<ListUsersResponse>("/api/v1/users"),
createUser: (payload: { username: string; displayName?: string | null; password: string }) =>
request<UserResponse>("/api/v1/users", {
method: "POST",
body: JSON.stringify(payload),
}),
updateUser: (userId: string, payload: { displayName?: string | null; password?: string; isDisabled?: boolean }) =>
request<UserResponse>(`/api/v1/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
logout: () =>
request<{ ok: true }>("/api/v1/auth/logout", {
method: "POST",
+78 -1
View File
@@ -159,6 +159,47 @@
}
}
},
"/api/v1/users": {
"get": {
"summary": "List instance users (owner only)",
"responses": {
"200": {
"description": "Instance user list",
"content": { "application/json": { "schema": {
"type": "object",
"properties": { "users": { "type": "array", "items": { "$ref": "#/components/schemas/InstanceUser" } } },
"required": ["users"]
} } }
},
"403": { "$ref": "#/components/responses/Error" }
}
},
"post": {
"summary": "Create an isolated user workspace (owner only)",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserCreateInput" } } } },
"responses": {
"201": { "description": "Created user", "content": { "application/json": { "schema": {
"type": "object", "properties": { "user": { "$ref": "#/components/schemas/InstanceUser" } }, "required": ["user"]
} } } },
"403": { "$ref": "#/components/responses/Error" },
"409": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/users/{id}": {
"patch": {
"summary": "Update, disable, or reset a user (owner only)",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserUpdateInput" } } } },
"responses": {
"200": { "description": "Updated user", "content": { "application/json": { "schema": {
"type": "object", "properties": { "user": { "$ref": "#/components/schemas/InstanceUser" } }, "required": ["user"]
} } } },
"403": { "$ref": "#/components/responses/Error" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/notebooks": {
"get": {
"summary": "List notebooks",
@@ -1261,9 +1302,45 @@
},
"displayName": {
"type": ["string", "null"]
},
"role": {
"type": "string",
"enum": ["owner", "member"]
}
},
"required": ["id", "username", "displayName"]
"required": ["id", "username", "displayName", "role"]
},
"InstanceUser": {
"allOf": [
{ "$ref": "#/components/schemas/AuthUser" },
{
"type": "object",
"properties": {
"isDisabled": { "type": "boolean" },
"lastLoginAt": { "type": ["string", "null"], "format": "date-time" },
"createdAt": { "type": "string", "format": "date-time" }
},
"required": ["isDisabled", "lastLoginAt", "createdAt"]
}
]
},
"UserCreateInput": {
"type": "object",
"properties": {
"username": { "type": "string", "minLength": 1, "maxLength": 80 },
"displayName": { "type": ["string", "null"], "maxLength": 80 },
"password": { "type": "string", "minLength": 8, "maxLength": 512 }
},
"required": ["username", "password"]
},
"UserUpdateInput": {
"type": "object",
"properties": {
"displayName": { "type": ["string", "null"], "maxLength": 80 },
"password": { "type": "string", "minLength": 8, "maxLength": 512 },
"isDisabled": { "type": "boolean" }
},
"minProperties": 1
},
"LoginInput": {
"type": "object",
+61
View File
@@ -0,0 +1,61 @@
PRAGMA foreign_keys = ON;
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL CHECK (length(trim(name)) > 0),
is_personal INTEGER NOT NULL DEFAULT 1 CHECK (is_personal IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE workspace_members (
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (workspace_id, user_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_workspace_members_personal_user
ON workspace_members(user_id);
CREATE INDEX idx_workspace_members_workspace
ON workspace_members(workspace_id, role);
CREATE UNIQUE INDEX idx_workspace_single_owner
ON workspace_members(workspace_id)
WHERE role = 'owner';
INSERT INTO workspaces (id, name, is_personal)
VALUES ('ws_default', 'Personal workspace', 1);
-- Existing EdgeEver instances were single-user. Their first enabled user owns
-- the legacy workspace; if no user exists yet, the login bootstrap claims it.
INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
SELECT 'ws_default', id, 'owner'
FROM users
WHERE is_disabled = 0
ORDER BY created_at ASC
LIMIT 1;
ALTER TABLE notebooks ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'ws_default';
ALTER TABLE memos ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'ws_default';
ALTER TABLE api_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'ws_default';
CREATE INDEX idx_notebooks_workspace_parent
ON notebooks(workspace_id, parent_id, is_deleted, sort_order, name);
CREATE INDEX idx_memos_workspace_notebook_feed
ON memos(workspace_id, notebook_id, is_deleted, updated_at DESC);
CREATE INDEX idx_memos_workspace_archive_feed
ON memos(workspace_id, is_archived, is_deleted, updated_at DESC);
CREATE INDEX idx_api_tokens_workspace
ON api_tokens(workspace_id, is_revoked, created_at DESC);
+16
View File
@@ -67,6 +67,20 @@ export const ChangePasswordSchema = z
path: ["confirmPassword"],
});
export const UserCreateSchema = z.object({
username: z.string().trim().min(1).max(80),
displayName: z.string().trim().max(80).nullable().optional(),
password: z.string().min(8).max(512),
});
export const UserUpdateSchema = z
.object({
displayName: z.string().trim().max(80).nullable().optional(),
password: z.string().min(8).max(512).optional(),
isDisabled: z.boolean().optional(),
})
.refine((input) => Object.keys(input).length > 0, "At least one field is required.");
export const ApiTokenCreateSchema = z.object({
name: z.string().trim().min(1).max(80),
scopes: z.array(z.string().trim().min(1).max(80)).min(1).max(32),
@@ -86,5 +100,7 @@ export type DeleteMemosInput = z.infer<typeof DeleteMemosSchema>;
export type MergeMemosInput = z.infer<typeof MergeMemosSchema>;
export type LoginInput = z.infer<typeof LoginSchema>;
export type ChangePasswordInput = z.infer<typeof ChangePasswordSchema>;
export type UserCreateInput = z.infer<typeof UserCreateSchema>;
export type UserUpdateInput = z.infer<typeof UserUpdateSchema>;
export type ApiTokenCreateInput = z.infer<typeof ApiTokenCreateSchema>;
export type TagRenameInput = z.infer<typeof TagRenameSchema>;
+7
View File
@@ -117,6 +117,13 @@ export type AuthUser = {
id: string;
username: string;
displayName: string | null;
role: "owner" | "member";
};
export type InstanceUser = AuthUser & {
isDisabled: boolean;
lastLoginAt: string | null;
createdAt: string;
};
export type AuthSession = {