5 Commits
Author SHA1 Message Date
justme 059a29a8d5 feat(web): ask for a display name when creating an account [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 18:30:59 +08:00
justme 915992776d feat(web): show the space validity period in the top bar 2026-10-16 18:30:59 +08:00
justme 6d6490055e feat(web): tidy the channel sidebar and let owners delete groups 2026-10-16 18:30:58 +08:00
justme 6310f93469 fix(web): verify deletions against the signer and read crypto state afresh 2026-10-16 18:30:58 +08:00
justme fc1dc08ebe feat(api): delete workspace groups and record who signed a content event 2026-10-16 18:30:58 +08:00
28 changed files with 188 additions and 37 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "8.0.0" "version": "8.1.0"
} }
+11 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"x-backend-contract-sha256": "655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5", "x-backend-contract-sha256": "22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350",
"$defs": { "$defs": {
"AccountChannelBody": { "AccountChannelBody": {
"type": "object", "type": "object",
@@ -1616,6 +1616,16 @@
"signature": { "signature": {
"type": "string" "type": "string"
}, },
"signerAccount": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"signerDeviceId": { "signerDeviceId": {
"type": "string" "type": "string"
}, },
+1 -1
View File
@@ -4,7 +4,7 @@
空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。 空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。 频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.0.0`。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。 URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.1.0`(8.1 新增 `DELETE /spaces/{id}/groups/{groupId}`:删除分组,组内频道回到未分组)。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。 `GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
+32 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: WebDrop API title: WebDrop API
version: 8.0.0 version: 8.1.0
servers: servers:
- url: /api/v1 - url: /api/v1
@@ -291,6 +291,37 @@ paths:
'403': { $ref: '#/components/responses/Error' } '403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' } '409': { $ref: '#/components/responses/Error' }
delete:
tags: [channel]
summary: 删除分组;组内频道回到未分组,频道与内容保留
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: groupId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/spaces/{id}/folders: /spaces/{id}/folders:
post: post:
tags: [channel] tags: [channel]
+1 -1
View File
@@ -6,5 +6,5 @@ public static class ApiContract
// 去掉设备名与设备清单接口。设备只是账号的属性。 // 去掉设备名与设备清单接口。设备只是账号的属性。
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。 // v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "8.0.0"; public const string Version = "8.1.0";
} }
@@ -42,6 +42,8 @@ public static partial class Endpoints
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody); return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
}); });
api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) =>
Json(View(svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{ {
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct); var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
+1 -1
View File
@@ -568,7 +568,7 @@ public sealed partial class ChannelService(
{ {
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount, EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'), ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
SignerDeviceId = signedEvent.DeviceId, SignerDeviceId = signedEvent.DeviceId, SignerAccount = signedEvent.Username,
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'), Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
TargetItemId = signedEvent.TargetItemId, TargetItemId = signedEvent.TargetItemId,
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey, SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
@@ -84,6 +84,13 @@ public sealed partial class ChannelService
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State; public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) => public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name)); (state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
/// <summary>删除分组:组里的频道回到未分组,频道本身和内容都不动</summary>
public WorkspaceState DeleteWorkspaceGroup(Id16 id, Credentials creds, string groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
{
var group = state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound);
foreach (var channel in state.Channels.Where(c => c.GroupId == groupId)) channel.GroupId = null;
state.Groups.Remove(group);
});
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) => public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) })); state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) => public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
@@ -30,6 +30,8 @@ public sealed class SignedContentEventRecord
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; } [JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; } [JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; } [JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
/// <summary>签名者的账号。空间主撤回别人的消息时,签名者不是作者;旧记录没有这个字段,按作者处理</summary>
[JsonPropertyName("signerAccount")] public string? SignerAccount { get; init; }
[JsonPropertyName("signature")] public required string Signature { get; init; } [JsonPropertyName("signature")] public required string Signature { get; init; }
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; } [JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; } [JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
@@ -45,6 +45,26 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<Serv
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength()); using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
} }
[Fact] [Fact]
public async Task DeletingAGroupMovesItsChannelsBackToUngrouped()
{
var a = await server.CreateAsync();
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/groups", a.Owner, JsonBody(new { name = "待删分组" }));
using var groupResponse = await server.Http.SendAsync(createGroup);
var groupId = (await ReadJsonAsync(groupResponse)).GetProperty("group").GetProperty("id").GetString();
using var createChannel = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "组内频道", groupId }));
using var channelResponse = await server.Http.SendAsync(createChannel); Assert.Equal(HttpStatusCode.Created, channelResponse.StatusCode);
using var delete = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner);
using var deleted = await server.Http.SendAsync(delete); Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
var structure = (await ReadJsonAsync(deleted)).GetProperty("structure");
Assert.DoesNotContain(structure.GetProperty("groups").EnumerateArray(), g => g.GetProperty("id").GetString() == groupId);
var moved = structure.GetProperty("channels").EnumerateArray().Single(ch => ch.GetProperty("name").GetString() == "组内频道");
Assert.True(!moved.TryGetProperty("groupId", out var remaining) || remaining.ValueKind == System.Text.Json.JsonValueKind.Null);
using var again = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner);
using var missing = await server.Http.SendAsync(again); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
}
[Fact]
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation() public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
{ {
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" }); await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
+1
View File
@@ -27,6 +27,7 @@ export interface WorkspaceApi {
createThread(name: string, groupId?: string): Promise<string>; createThread(name: string, groupId?: string): Promise<string>;
createGroup(name: string): Promise<string>; createGroup(name: string): Promise<string>;
renameGroup(id: string, name: string): Promise<void>; renameGroup(id: string, name: string): Promise<void>;
deleteGroup(id: string): Promise<void>;
groupThread(id: string, groupId?: string): Promise<void>; groupThread(id: string, groupId?: string): Promise<void>;
renameThread(threadId: string, name: string): Promise<void>; renameThread(threadId: string, name: string): Promise<void>;
setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): Promise<void>; setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): Promise<void>;
+2 -1
View File
@@ -1,5 +1,5 @@
// Generated from backend DTOs; do not edit. // Generated from backend DTOs; do not edit.
// backend-contract-sha256: 655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5 // backend-contract-sha256: 22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350
export interface AccountChannelBody { export interface AccountChannelBody {
channel_id: string; channel_id: string;
@@ -338,6 +338,7 @@ export interface SignedContentEventRecord {
kind: string; kind: string;
manifestHash: string; manifestHash: string;
signature: string; signature: string;
signerAccount?: string | null;
signerDeviceId: string; signerDeviceId: string;
signerDevicePublicKey: string; signerDevicePublicKey: string;
signerRootPublicKey: string; signerRootPublicKey: string;
+1
View File
@@ -39,6 +39,7 @@ export const realApi: WorkspaceApi = {
sendFiles: (files, options, threadId, folderId, localIds) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }), sendFiles: (files, options, threadId, folderId, localIds) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }),
createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }), createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }), createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
deleteGroup: (groupId) => callWorker<void>('deleteGroup', { id: currentChannelId(), groupId, apiBase: API_BASE }),
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }), renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
groupThread: (threadId, groupId) => callWorker<void>('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }), groupThread: (threadId, groupId) => callWorker<void>('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }),
renameThread: (threadId, name) => callWorker<void>('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }), renameThread: (threadId, name) => callWorker<void>('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }),
+2 -1
View File
@@ -12,7 +12,7 @@ import { setEventSink } from './worker/port';
import { serial } from './worker/serial'; import { serial } from './worker/serial';
import { errorMessage } from './errors'; import { errorMessage } from './errors';
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; import { createFolder, createThread, createGroup, renameGroup, deleteGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
interface WorkerRequest { requestId: number; operation: string; payload: unknown } interface WorkerRequest { requestId: number; operation: string; payload: unknown }
@@ -62,6 +62,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters<typeof updateChannelSettings>[1]); case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters<typeof updateChannelSettings>[1]);
case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
case 'createGroup': return createGroup(id, String(payload.name ?? '')); case 'createGroup': return createGroup(id, String(payload.name ?? ''));
case 'deleteGroup': return deleteGroup(id, String(payload.groupId ?? ''));
case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? '')); case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? ''));
case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
case 'renameThread': return renameThread(id, String(payload.threadId ?? ''), String(payload.name ?? '')); case 'renameThread': return renameThread(id, String(payload.threadId ?? ''), String(payload.name ?? ''));
+27 -9
View File
@@ -37,13 +37,29 @@ export function attachDataKeys(session: ChannelSession, resource: SavedResource)
}; };
} }
type DataKeys = { state: CryptoState; keys: Record<string, string> }; type DataKeys = { state: CryptoState; keys: Record<string, string> };
const pendingKeys = new Map<string, Promise<DataKeys>>(); const runningKeys = new Map<string, Promise<DataKeys>>();
/** 同一空间同时发起的读取合并成一次请求 */ const queuedKeys = new Map<string, Promise<DataKeys>>();
export function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> { function startKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
let task = pendingKeys.get(resource.id); const task: Promise<DataKeys> = loadDataKeys(resource, session).finally(() => { if (runningKeys.get(resource.id) === task) runningKeys.delete(resource.id); });
if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); } runningKeys.set(resource.id, task);
return task; return task;
} }
/**
* 读取空间的加密状态。fresh(默认)表示调用方刚发现本地缺对象或事件签名,需要一份比现在更新的结果:
* 已有请求在途时不能直接借用它(它可能在新对象登记之前就发出了),而是排在它后面再请求一次,同时到达的调用合并成这一次。
* 不要求最新(首次打开)的调用可以直接借用在途请求。
*/
export function readDataKeys(resource: SavedResource, session: ChannelSession, fresh = true): Promise<DataKeys> {
const running = runningKeys.get(resource.id);
if (!running) return startKeys(resource, session);
if (!fresh) return running;
let next = queuedKeys.get(resource.id);
if (!next) {
next = running.catch(() => undefined).then(() => { queuedKeys.delete(resource.id); return startKeys(resource, session); });
queuedKeys.set(resource.id, next);
}
return next;
}
/** 内存里的结果满足 enough 就复用,不满足再请求 */ /** 内存里的结果满足 enough 就复用,不满足再请求 */
async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> { async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> {
const have = loaded.get(resource.id); const have = loaded.get(resource.id);
@@ -222,18 +238,20 @@ export async function verifyObjectDeletion(resource: SavedResource, session: Cha
const eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32); const eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32);
const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64); const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64);
if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false; if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false;
// 撤回别人的消息时(空间主),删除事件由撤回者签名,不是作者;旧记录没有 signerAccount,按作者处理
const signer = deleted.signerAccount || object.authorAccount;
const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey; const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey;
const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey; const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey;
const root = await importSigningPublicKey(rootJwk); const root = await importSigningPublicKey(rootJwk);
const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); const { unlockedAccount } = await import('./account'); const local = unlockedAccount();
if (object.authorAccount === local.username && local.profile.keys.signing if (signer === local.username && local.profile.keys.signing
&& (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false;
await pinSigningRoot(object.authorAccount, rootJwk); await pinSigningRoot(signer, rootJwk);
if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) return false; if (!await verifyDeviceCertificate(signer, deleted.signerDeviceId, deviceJwk, certificate, root)) return false;
const domain = object.domain; const domain = object.domain;
const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete'; const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete';
if (deleted.kind !== expectedKind) return false; if (deleted.kind !== expectedKind) return false;
const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount, const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: signer,
deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch, deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch,
itemId: itemBytes, frameCount: object.event.frameCount, manifestHash }); itemId: itemBytes, frameCount: object.event.frameCount, manifestHash });
const deviceKey = await importSigningPublicKey(deviceJwk); const deviceKey = await importSigningPublicKey(deviceJwk);
+3 -2
View File
@@ -110,7 +110,8 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
break; break;
} }
case 'removed': case 'removed':
if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId)) { // 验签过程中的异常(例如签名公钥与已固定的不一致)按「拒绝这条记录」处理,不能让整条连接断开重连
if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId).catch(() => false)) {
showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' }); showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' });
break; break;
} }
@@ -145,7 +146,7 @@ export async function syncChannel(resource: SavedResource, session: ChannelSessi
clearFileCatalog(resource.id); clearFileCatalog(resource.id);
readers.set(resource.id, session.createItemReader()); readers.set(resource.id, session.createItemReader());
// 密钥与第一页日志互不依赖,同时发出,首次打开少等一个往返 // 密钥与第一页日志互不依赖,同时发出,首次打开少等一个往返
const keys = resource.mode === 'enc' ? readDataKeys(resource, session) : undefined; const keys = resource.mode === 'enc' ? readDataKeys(resource, session, false) : undefined;
keys?.catch(() => undefined); keys?.catch(() => undefined);
let from = 0; let from = 0;
for (let first = true; ; first = false) { for (let first = true; ; first = false) {
@@ -24,6 +24,9 @@ export async function createGroup(resourceId: string, name: string): Promise<str
export async function renameGroup(resourceId: string, id: string, name: string): Promise<void> { export async function renameGroup(resourceId: string, id: string, name: string): Promise<void> {
await changeWorkspace(resourceId, 'groups/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); await changeWorkspace(resourceId, 'groups/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest);
} }
export async function deleteGroup(resourceId: string, id: string): Promise<void> {
await changeWorkspace(resourceId, 'groups/' + id, 'DELETE');
}
export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> { export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> {
await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest); await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest);
} }
@@ -37,7 +37,7 @@ export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>); const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
return publish(id, body); return publish(id, body);
} }
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT', value: unknown): Promise<WorkspaceBody> { export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise<WorkspaceBody> {
const { session } = await getSession(id); const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/${path}`, { const body = await apiFetch(`/spaces/${id}/${path}`, {
method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value), method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value),
+1 -1
View File
@@ -1,6 +1,6 @@
.pad { .pad {
margin-top: var(--space-2); margin-top: var(--space-2);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-3);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
@@ -105,6 +105,15 @@
border: 0; border: 0;
} }
.dangerZone {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: flex-start;
padding-top: var(--space-2);
border-top: 1px solid var(--line);
}
.modalActions { .modalActions {
justify-content: flex-end; justify-content: flex-end;
margin-top: var(--space-2); margin-top: var(--space-2);
@@ -147,11 +156,19 @@
.heading { .heading {
min-height: 32px; min-height: 32px;
padding-inline: var(--space-1); padding-inline: var(--space-1) 0;
color: var(--text-dim); color: var(--text-dim);
font-size: 12px; font-size: 12px;
} }
.heading button {
width: 26px;
min-width: 26px;
height: 26px;
min-height: 26px;
padding: 0;
}
.folderHeading { .folderHeading {
padding-top: var(--space-2); padding-top: var(--space-2);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -167,9 +184,8 @@
list-style: none; list-style: none;
} }
/* 频道行缩进到分组标题文字之下 */
.threads { .threads {
padding-inline-start: var(--space-4); padding-inline-start: 0;
} }
.nestedThreads { .nestedThreads {
@@ -219,11 +235,13 @@
gap: var(--space-2); gap: var(--space-2);
min-width: 0; min-width: 0;
min-height: 32px; min-height: 32px;
padding: 0 var(--space-2); padding: 0 var(--space-2) 0 var(--space-1);
border: 0; border: 0;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: transparent; background: transparent;
color: var(--text-dim); color: var(--text-dim);
font: inherit;
font-size: 14px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
@@ -259,12 +277,14 @@
.thread.selected { .thread.selected {
background: var(--accent-soft); background: var(--accent-soft);
color: var(--text); color: var(--text);
font-weight: 600; font-weight: 500;
} }
.thread > span:first-child { .thread > span:first-child {
width: 14px;
color: var(--text-dim); color: var(--text-dim);
font-size: 16px; font-size: 14px;
text-align: center;
} }
.empty, .empty,
+20 -2
View File
@@ -17,6 +17,7 @@ interface Props {
onRenameThread: (id: string, name: string) => Promise<void>; onRenameThread: (id: string, name: string) => Promise<void>;
onCreateGroup: (name: string) => Promise<string>; onCreateGroup: (name: string) => Promise<string>;
onRenameGroup: (id: string, name: string) => Promise<void>; onRenameGroup: (id: string, name: string) => Promise<void>;
onDeleteGroup: (id: string) => Promise<void>;
onGroupThread: (id: string, groupId?: string) => Promise<void>; onGroupThread: (id: string, groupId?: string) => Promise<void>;
} }
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string }; type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
@@ -29,16 +30,24 @@ export function ThreadSidebar(props: Props) {
const [chatHistory, setChatHistory] = useState(true); const [chatHistory, setChatHistory] = useState(true);
const [filesHistory, setFilesHistory] = useState(true); const [filesHistory, setFilesHistory] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set()); const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
const knownGroups = new Set(props.groups.map((group) => group.id)); const knownGroups = new Set(props.groups.map((group) => group.id));
const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId)); const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId));
function begin(target: Editor, initial = '') { function begin(target: Editor, initial = '') {
setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); setConfirmDelete(false);
const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true); const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true);
} }
function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
function select(id: string) { props.onSelectThread(id); setEdit(undefined); } function select(id: string) { props.onSelectThread(id); setEdit(undefined); }
async function removeGroup() {
if (!edit?.id || busy || !props.enabled) return;
setBusy(true); setError('');
try { await props.onDeleteGroup(edit.id); setEdit(undefined); }
catch (cause) { setError(cause instanceof Error ? cause.message : '删除失败'); }
finally { setBusy(false); }
}
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); if (!edit || !name.trim() || busy || !props.enabled) return; event.preventDefault(); if (!edit || !name.trim() || busy || !props.enabled) return;
setBusy(true); setError(''); setBusy(true); setError('');
@@ -57,7 +66,7 @@ export function ThreadSidebar(props: Props) {
finally { setBusy(false); } finally { setBusy(false); }
} }
return <section className={styles.panel} aria-label="空间频道导航"> return <section className={styles.panel} aria-label="空间频道导航">
<div className={styles.heading}><strong>频道</strong><Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} /></div> <div className={styles.heading}><strong>频道</strong><Button variant="ghost" icon="plus" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} /></div>
{(ungrouped.length > 0 || props.groups.length === 0) ? <section className={styles.group} aria-label="未分组频道"> {(ungrouped.length > 0 || props.groups.length === 0) ? <section className={styles.group} aria-label="未分组频道">
<div className={styles.groupHeading}> <div className={styles.groupHeading}>
<button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}> <button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}>
@@ -94,6 +103,15 @@ export function ThreadSidebar(props: Props) {
<label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label> <label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label> <label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
</> : null} </> : null}
{edit.kind === 'group' && edit.id ? <div className={styles.dangerZone}>
{confirmDelete ? <>
<p className={styles.hint}>分组会被删除,其中的频道回到「未分组」,频道和消息都不会丢。</p>
<div className={styles.groupActions}>
<Button variant="ghost" disabled={busy} onClick={() => setConfirmDelete(false)}>先不删</Button>
<Button variant="danger" disabled={busy} onClick={() => { void removeGroup(); }}>确认删除分组</Button>
</div>
</> : <Button variant="ghost" icon="trash" disabled={busy} onClick={() => setConfirmDelete(true)}>删除分组</Button>}
</div> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null} {error ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={`${styles.groupActions} ${styles.modalActions}`}> <div className={`${styles.groupActions} ${styles.modalActions}`}>
<Button variant="ghost" disabled={busy} onClick={() => setEdit(undefined)}>取消</Button> <Button variant="ghost" disabled={busy} onClick={() => setEdit(undefined)}>取消</Button>
+2
View File
@@ -1,3 +1,4 @@
import { formatRemaining } from '@/lib/format';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
@@ -34,6 +35,7 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, onOpenMembers, onI
<span className={styles.name}>{channel?.name || `${channel?.id.slice(0, 8)}…`}</span> <span className={styles.name}>{channel?.name || `${channel?.id.slice(0, 8)}…`}</span>
{channel?.name ? <span className={styles.idSuffix}>{channel.id.slice(0, 8)}…</span> : null} {channel?.name ? <span className={styles.idSuffix}>{channel.id.slice(0, 8)}…</span> : null}
{channel?.creatorAccount ? <span className={styles.idSuffix}>创建者 {channel.creatorAccount}</span> : null} {channel?.creatorAccount ? <span className={styles.idSuffix}>创建者 {channel.creatorAccount}</span> : null}
{channel?.expiresAt ? <span className={styles.idSuffix} title={`到期时间 ${new Date(channel.expiresAt).toLocaleString('zh-CN', { hour12: false })}。空间有新内容时会自动顺延`}>{formatRemaining(channel.expiresAt).replace(/^剩 /, '有效期 ')}</span> : null}
{raw ? ( {raw ? (
<span className={styles.badgeWarn}>明文 · 服务端可见</span> <span className={styles.badgeWarn}>明文 · 服务端可见</span>
) : ( ) : (
+2 -1
View File
@@ -43,6 +43,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const setChannelHistory = useWorkspace(s => s.setChannelHistory); const setChannelHistory = useWorkspace(s => s.setChannelHistory);
const createGroup = useWorkspace((s) => s.createGroup); const createGroup = useWorkspace((s) => s.createGroup);
const renameGroup = useWorkspace((s) => s.renameGroup); const renameGroup = useWorkspace((s) => s.renameGroup);
const deleteGroup = useWorkspace((s) => s.deleteGroup);
const groupThread = useWorkspace((s) => s.groupThread); const groupThread = useWorkspace((s) => s.groupThread);
const folders = useWorkspace((s) => s.folders); const folders = useWorkspace((s) => s.folders);
const sendText = useWorkspace((s) => s.sendText); const sendText = useWorkspace((s) => s.sendText);
@@ -127,7 +128,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />; const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />; onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />;
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>; const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <> const explorer = <>
+5 -3
View File
@@ -25,6 +25,7 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const refreshShares = useShares((s) => s.refresh); const refreshShares = useShares((s) => s.refresh);
const [register, setRegister] = useState(true); const [register, setRegister] = useState(true);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState(''); const [confirm, setConfirm] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@@ -63,8 +64,8 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
if (creating && password !== confirm) { setMessage('两次输入的密码不一致'); return; } if (creating && password !== confirm) { setMessage('两次输入的密码不一致'); return; }
setMessage(''); setMessage('');
try { try {
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember); if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName);
setPassword(''); setConfirm(''); setPassword(''); setConfirm(''); setDisplayName('');
await Promise.all([refreshChannels(), refreshShares()]); await Promise.all([refreshChannels(), refreshShares()]);
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。'); setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
const pending = useChannels.getState().pendingLink; const pending = useChannels.getState().pendingLink;
@@ -90,13 +91,14 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
</div> : null} </div> : null}
{unlockUsername ? <p className={styles.note}>已用Passkey确认账号,请输入账号密码解锁资源。</p> : null} {unlockUsername ? <p className={styles.note}>已用Passkey确认账号,请输入账号密码解锁资源。</p> : null}
<label>账号 ID<input name="username" autoComplete="username" minLength={3} maxLength={32} value={unlockUsername ?? username} disabled={Boolean(unlockUsername)} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label> <label>账号 ID<input name="username" autoComplete="username" minLength={3} maxLength={32} value={unlockUsername ?? username} disabled={Boolean(unlockUsername)} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
{creating ? <label>个人名称(可选)<input name="display-name" autoComplete="nickname" maxLength={48} value={displayName} placeholder={username || '显示给其他成员看的名字'} onChange={(event) => { edited.current = true; setDisplayName(event.target.value); }} /></label> : null}
<label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label> <label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label>
{creating ? <label>确认密码<input name="confirm-password" autoComplete="new-password" type="password" minLength={12} value={confirm} aria-invalid={mismatch} onChange={(event) => { edited.current = true; setConfirm(event.target.value); }} required /> {creating ? <label>确认密码<input name="confirm-password" autoComplete="new-password" type="password" minLength={12} value={confirm} aria-invalid={mismatch} onChange={(event) => { edited.current = true; setConfirm(event.target.value); }} required />
</label> : null} </label> : null}
{creating ? <ul className={styles.rules} aria-label="注册要求"> {creating ? <ul className={styles.rules} aria-label="注册要求">
{rules.map((rule) => <li key={rule.text} data-met={rule.met}><span aria-hidden="true">{rule.met ? '✓' : '○'}</span>{rule.text}<span className={styles.srOnly}>{rule.met ? ',已满足' : ',未满足'}</span></li>)} {rules.map((rule) => <li key={rule.text} data-met={rule.met}><span aria-hidden="true">{rule.met ? '✓' : '○'}</span>{rule.text}<span className={styles.srOnly}>{rule.met ? ',已满足' : ',未满足'}</span></li>)}
</ul> : null} </ul> : null}
{creating ? <p className={styles.note}>账号 ID 是登录用的唯一标识,不是显示名称;显示名称可在登录后的个人资料里设置。密码用于在本机加密你的资源保险库,<strong>忘记后无法找回</strong>,请妥善保管。</p> : null} {creating ? <p className={styles.note}>账号 ID 是登录用的唯一标识,注册后不能改;个人名称是其他成员看到的名字,留空就显示账号 ID,之后可在个人资料里修改。密码用于在本机加密你的资源保险库,<strong>忘记后无法找回</strong>,请妥善保管。</p> : null}
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label> <label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
<div className={styles.actions}> <div className={styles.actions}>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady || unmet}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button> <Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady || unmet}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
+5 -3
View File
@@ -12,7 +12,7 @@ import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
interface AccountState extends AccountStatus { interface AccountState extends AccountStatus {
busy: boolean; busy: boolean;
hydrated: boolean; hydrated: boolean;
login: (username: string, password: string, create: boolean, remember?: boolean) => Promise<void>; login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string) => Promise<void>;
passkeyLogin: (username: string, remember: boolean) => Promise<void>; passkeyLogin: (username: string, remember: boolean) => Promise<void>;
unlockPasskey: (password: string, remember: boolean) => Promise<void>; unlockPasskey: (password: string, remember: boolean) => Promise<void>;
cancelPasskey: () => Promise<void>; cancelPasskey: () => Promise<void>;
@@ -46,10 +46,12 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; } catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; }
}, },
cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); }, cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); },
login: async (username, password, create, remember = true) => { login: async (username, password, create, remember = true, displayName = '') => {
set({ busy: true, error: undefined }); set({ busy: true, error: undefined });
try { try {
const status = create ? await accountApi.register(username, password, remember) : await accountApi.login(username, password, remember); let status = create ? await accountApi.register(username, password, remember) : await accountApi.login(username, password, remember);
// 注册时填的个人名称:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && displayName.trim()) status = await accountApi.updatePersonal(displayName.trim()).catch(() => status);
clearViews(); await refreshResources(); clearViews(); await refreshResources();
set({ ...status, busy: false, hydrated: true }); set({ ...status, busy: false, hydrated: true });
} catch (error) { } catch (error) {
+2
View File
@@ -66,6 +66,7 @@ interface Actions {
createThread: (name: string, groupId?: string) => Promise<string>; createThread: (name: string, groupId?: string) => Promise<string>;
createGroup: typeof api.createGroup; createGroup: typeof api.createGroup;
renameGroup: typeof api.renameGroup; renameGroup: typeof api.renameGroup;
deleteGroup: typeof api.deleteGroup;
groupThread: typeof api.groupThread; groupThread: typeof api.groupThread;
renameThread: (threadId: string, name: string) => Promise<void>; renameThread: (threadId: string, name: string) => Promise<void>;
setChannelHistory: typeof api.setChannelHistory; setChannelHistory: typeof api.setChannelHistory;
@@ -112,6 +113,7 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
createThread: (name, groupId) => api.createThread(name, groupId), createThread: (name, groupId) => api.createThread(name, groupId),
createGroup: (name) => api.createGroup(name), createGroup: (name) => api.createGroup(name),
renameGroup: (id, name) => api.renameGroup(id, name), renameGroup: (id, name) => api.renameGroup(id, name),
deleteGroup: (id) => api.deleteGroup(id),
groupThread: (id, groupId) => api.groupThread(id, groupId), groupThread: (id, groupId) => api.groupThread(id, groupId),
renameThread: (threadId, name) => api.renameThread(threadId, name), renameThread: (threadId, name) => api.renameThread(threadId, name),
setChannelHistory: (threadId, chatHistory, filesHistory) => api.setChannelHistory(threadId, chatHistory, filesHistory), setChannelHistory: (threadId, chatHistory, filesHistory) => api.setChannelHistory(threadId, chatHistory, filesHistory),
+6
View File
@@ -34,6 +34,12 @@
font-weight: 600; font-weight: 600;
} }
.danger {
background: var(--danger);
color: var(--on-accent);
font-weight: 600;
}
.quiet { .quiet {
border-color: var(--line-strong); border-color: var(--line-strong);
background: var(--surface); background: var(--surface);
+1 -1
View File
@@ -3,7 +3,7 @@ import { Icon, type IconName } from './Icon';
import styles from './Button.module.css'; import styles from './Button.module.css';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'quiet' | 'ghost'; variant?: 'primary' | 'quiet' | 'ghost' | 'danger';
icon?: IconName; icon?: IconName;
/** 仅图标时必填,作为无障碍名称 */ /** 仅图标时必填,作为无障碍名称 */
label?: string; label?: string;