feat(web)!: sign in before joining a space and drop the stranger option
Opening a space link while signed out shows the sign-in page first and keeps the link in memory. The channel settings no longer offer the stranger switch, and the client supports API contract major 8.
This commit is contained in:
1 parent
c858bd2a09
commit
8233369cd4
16 files changed
+27
-30
No files matched your search
@@ -134,7 +134,7 @@ const { complete, nextIndex, meta } = await result;
|
||||
|
||||
## 必须注意的几点
|
||||
|
||||
**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
||||
**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。
|
||||
|
||||
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export type AuthRole = 'read' | 'write' | 'owner';
|
||||
/** 频道设置,字段名与 API 一致(snake_case)。 */
|
||||
export interface ChannelSettings {
|
||||
join_policy?: 'open' | 'invite';
|
||||
allow_guests?: boolean;
|
||||
name?: string;
|
||||
max_members?: number;
|
||||
members_can_upload?: boolean;
|
||||
|
||||
@@ -45,7 +45,8 @@ let dataDir = '';
|
||||
let base = '';
|
||||
let accountToken = '';
|
||||
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
|
||||
return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) };
|
||||
// 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现
|
||||
return { ...session.authHeaders(role), 'X-WD-Account': accountToken };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -145,7 +146,7 @@ function subscribe(session: ChannelSession, role: AuthRole, until: (events: Item
|
||||
const h = headersFor(session, role);
|
||||
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
|
||||
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
|
||||
if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
|
||||
protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
|
||||
|
||||
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
@@ -416,7 +417,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
await joinChannel(member, 'read');
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, 'read') } });
|
||||
const res = await fetch(api(`/channels/${owner.id}/settings`), { method: 'PATCH', headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/json' }, body: '{}' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(() => headersFor(member, 'owner')).toThrow();
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ export function App() {
|
||||
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
|
||||
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
|
||||
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
|
||||
// 空间只接受登录账号:未登录先登录,链接留在内存里,登录后继续加入
|
||||
else if (route.name === 'channel' && !username) screen = <AccountScreen />;
|
||||
else if (saved) screen = <ChannelScreen id={route.id} />;
|
||||
else if (pendingLink?.id === route.id) {
|
||||
screen = (
|
||||
|
||||
@@ -3,9 +3,9 @@ import { supportsApiContract } from './contract';
|
||||
|
||||
describe('API contract compatibility', () => {
|
||||
it('accepts compatible SemVer releases in the supported major', () => {
|
||||
expect(supportsApiContract('7.0.0')).toBe(true);
|
||||
expect(supportsApiContract('7.8.2')).toBe(true);
|
||||
expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
|
||||
expect(supportsApiContract('8.0.0')).toBe(true);
|
||||
expect(supportsApiContract('8.8.2')).toBe(true);
|
||||
expect(supportsApiContract('8.2.0-rc.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a breaking major and malformed versions', () => {
|
||||
@@ -13,6 +13,7 @@ describe('API contract compatibility', () => {
|
||||
expect(supportsApiContract('4.0.0')).toBe(false);
|
||||
expect(supportsApiContract('5.0.0')).toBe(false);
|
||||
expect(supportsApiContract('6.1.0')).toBe(false);
|
||||
expect(supportsApiContract('7.0.0')).toBe(false);
|
||||
expect(supportsApiContract('01.0.0')).toBe(false);
|
||||
expect(supportsApiContract(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 7;
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 8;
|
||||
|
||||
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ import type { CreateOptions } from './types';
|
||||
export function creationOptions(defaults: DefaultsBody): CreateOptions {
|
||||
const settings = defaults.channel;
|
||||
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy };
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export interface ChannelInfo {
|
||||
dataSource?: 'server' | 'cache';
|
||||
creatorAccount?: string;
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
passwordRequired?: boolean;
|
||||
id: string;
|
||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||
@@ -33,7 +32,6 @@ export interface ChannelInfo {
|
||||
/** 新建空间的选项(protocol §12 设置的子集) */
|
||||
export interface CreateOptions {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
mode: ChannelMode;
|
||||
@@ -51,7 +49,6 @@ export interface FileUploadOptions {
|
||||
|
||||
export interface ChannelSettingsPatch {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
maxMembers?: number;
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
file_max_downloads: options.fileMaxDownloads,
|
||||
join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests,
|
||||
join_policy: options.joinPolicy ?? defaults.join_policy,
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -157,7 +157,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
|
||||
if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests;
|
||||
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
|
||||
const info = await apiFetch(`/channels/${resourceId}/settings`, {
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
|
||||
});
|
||||
|
||||
it('blocks API requests when the server has a newer breaking major', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }));
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '9.0.0' }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setApiBase('http://api.test/api/v1');
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
|
||||
|
||||
it('checks the contract once and then sends the API request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -8,7 +8,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
||||
return {
|
||||
id: raw.id,
|
||||
creatorAccount: raw.creator_account ?? undefined,
|
||||
joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required,
|
||||
joinPolicy, passwordRequired: raw.password_required,
|
||||
name: raw.settings.name ?? undefined,
|
||||
mode: raw.mode,
|
||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Props {
|
||||
|
||||
export function ChannelSettingsSheet(props: Props) {
|
||||
const { info, error, load } = useServerSettings();
|
||||
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined)
|
||||
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined)
|
||||
return <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
|
||||
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
|
||||
return <SettingsForm {...props} info={info} />;
|
||||
@@ -29,7 +29,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
|
||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests!);
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -42,7 +41,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -87,10 +86,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
|
||||
<label className={styles.field}><span>加入方式</span>
|
||||
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
|
||||
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||
<option value="open">登录后持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
|
||||
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
|
||||
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
|
||||
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { ArchiveEntry, Item } from '@/core-bridge/types';
|
||||
import { GUEST_LABEL } from '@/lib/sender';
|
||||
import { UNKNOWN_SENDER_LABEL } from '@/lib/sender';
|
||||
import { useShareSession } from '@/store/shareSession';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface ItemSource {
|
||||
|
||||
export const channelSource: ItemSource = {
|
||||
useItem: (id) => useWorkspace((s) => s.byId[id]),
|
||||
fallbackSender: GUEST_LABEL,
|
||||
fallbackSender: UNKNOWN_SENDER_LABEL,
|
||||
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
|
||||
listArchive: (id) => useWorkspace.getState().listArchive(id),
|
||||
actions: {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重
|
||||
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
|
||||
</dd>
|
||||
</dl>
|
||||
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : preview?.allowGuests === false ? '这个空间需要登录账号后加入。' : '未登录用户将以陌生人设备身份加入。'}符合加入规则后可读取空间历史。</p>
|
||||
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : '登录账号后即可加入。'}符合加入规则后可读取空间历史。</p>
|
||||
{preview?.passwordRequired ? <label>空间加入密码<input type="password" autoComplete="off" maxLength={256} value={password} onChange={(event) => setPassword(event.target.value)} /></label> : null}
|
||||
{phase === 'failed' ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={styles.row}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
|
||||
/** 没有账号归属的内容(未登录访客)的显示名;设备只是账号的属性,不作为发送者展示 */
|
||||
export const GUEST_LABEL = '访客';
|
||||
/** 找不到作者信息时的显示名(频道只有登录账号,正常不会用到);设备只是账号的属性,不作为发送者展示 */
|
||||
export const UNKNOWN_SENDER_LABEL = '未知成员';
|
||||
|
||||
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = GUEST_LABEL): string {
|
||||
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = UNKNOWN_SENDER_LABEL): string {
|
||||
return source?.author?.displayName || source?.author?.username || fallback;
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
// Restore the account scope before loading any account/guest resources.
|
||||
// Restore the account scope before loading any account resources.
|
||||
void useAccount.getState().refresh().then(() => Promise.all([
|
||||
useChannels.getState().hydrate(), useShares.getState().hydrate(),
|
||||
]));
|
||||
Reference in new issue
Block a user