Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
885ec7c82e | ||
|
|
7ad2a94e2e | ||
|
|
e1168f47bb | ||
|
|
14f05a3d5c | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ed494d2ae5 | ||
|
|
adf94d7bc1 |
+19
-1
@@ -118,8 +118,26 @@ test.describe('accessibility', () => {
|
||||
test('room settings', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'More Options' }).first().click();
|
||||
await page.getByText('Room Settings', { exact: true }).click();
|
||||
await expect(page.getByRole('dialog').first()).toBeVisible();
|
||||
// [Gitea #278] The dialog has an accessible name.
|
||||
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
|
||||
await auditPage(page, 'room settings');
|
||||
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
|
||||
// export date inputs were only on these pages.
|
||||
// One page at a time, in order.
|
||||
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(1000);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await auditPage(page, `room settings: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('inbox invites', async ({ page }) => {
|
||||
await page.goto('/inbox/invites/');
|
||||
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
|
||||
await auditPage(page, 'inbox invites');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
|
||||
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={value === ManualVerificationMethod.RecoveryKey}
|
||||
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
||||
>
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
type Modal500Props = {
|
||||
requestClose: () => void;
|
||||
children: ReactNode;
|
||||
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
|
||||
label: string;
|
||||
};
|
||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
export function Modal500({ requestClose, children, label }: Modal500Props) {
|
||||
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
@@ -26,6 +28,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
variant="Background"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||
// squish the two-pane settings layout.
|
||||
|
||||
@@ -117,7 +117,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
|
||||
const renderPlaceholder = useCallback(
|
||||
({ attributes, children }: RenderPlaceholderProps) => (
|
||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||
<span
|
||||
{...attributes}
|
||||
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
|
||||
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
|
||||
// measured 2.66:1. Drop it so the class decides.
|
||||
style={{ ...attributes.style, opacity: undefined }}
|
||||
className={css.EditorPlaceholderContainer}
|
||||
>
|
||||
{/* Inner component to style the actual text position and appearance */}
|
||||
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||
{children}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
|
||||
key={getUsageStr(usage)}
|
||||
size="300"
|
||||
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
|
||||
aria-selected={isSelected(usage)}
|
||||
aria-pressed={isSelected(usage)}
|
||||
radii="300"
|
||||
onClick={() => onChange(usage)}
|
||||
>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedDay === day}
|
||||
aria-pressed={selectedDay === day}
|
||||
onClick={() => handleDay(day)}
|
||||
disabled={
|
||||
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
||||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedMonth === month}
|
||||
aria-pressed={selectedMonth === month}
|
||||
onClick={() => handleMonth(month)}
|
||||
disabled={
|
||||
(selectedYear === minYear && month < minMonth) ||
|
||||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedYear === year}
|
||||
aria-pressed={selectedYear === year}
|
||||
onClick={() => handleYear(year)}
|
||||
>
|
||||
<Text size="T300">{year}</Text>
|
||||
|
||||
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={hour === selectedHour}
|
||||
aria-pressed={hour === selectedHour}
|
||||
onClick={() => handleHour(hour)}
|
||||
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||
>
|
||||
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={hour === selectedHour}
|
||||
aria-pressed={hour === selectedHour}
|
||||
onClick={() => handleHour(hour)}
|
||||
disabled={
|
||||
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={minute === selectedMinute}
|
||||
aria-pressed={minute === selectedMinute}
|
||||
onClick={() => handleMinute(minute)}
|
||||
disabled={
|
||||
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
||||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={!selectedPM}
|
||||
aria-pressed={!selectedPM}
|
||||
onClick={() => handlePeriod(false)}
|
||||
disabled={minDay && minPM}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedPM}
|
||||
aria-pressed={selectedPM}
|
||||
onClick={() => handlePeriod(true)}
|
||||
disabled={maxDay && !maxPM}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,9 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
<AvatarImage
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
// [Gitea #278] An avatar sits next to the person's name or inside a
|
||||
// labelled button, so without an explicit alt it is decorative.
|
||||
alt={alt ?? ''}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
draggable={false}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
|
||||
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
|
||||
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
|
||||
export const BookmarksPanel = style({
|
||||
width: toRem(266),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -109,8 +109,11 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
||||
alignItems="End"
|
||||
>
|
||||
<Box direction="Column" gap="100" grow="Yes">
|
||||
<Text size="L400">Name</Text>
|
||||
<Text as="label" htmlFor="room-pack-name" size="L400">
|
||||
Name
|
||||
</Text>
|
||||
<Input
|
||||
id="room-pack-name"
|
||||
name="nameInput"
|
||||
required
|
||||
size="400"
|
||||
|
||||
@@ -136,7 +136,8 @@ export function PermissionGroups({
|
||||
outlined={powerChanges}
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
aria-selected={opened}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={opened}
|
||||
disabled={!canEdit || applyingChanges}
|
||||
after={
|
||||
powerChanges && (
|
||||
@@ -200,7 +201,8 @@ export function PermissionGroups({
|
||||
outlined={powerChanges}
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
aria-selected={opened}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={opened}
|
||||
disabled={!canEdit || applyingChanges}
|
||||
after={
|
||||
powerChanges && (
|
||||
|
||||
@@ -353,8 +353,11 @@ ${msgRows}
|
||||
>
|
||||
<Box gap="400" wrap="Wrap">
|
||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||
<Text size="T300">From</Text>
|
||||
<Text as="label" htmlFor="export-from-date" size="T300">
|
||||
From
|
||||
</Text>
|
||||
<Input
|
||||
id="export-from-date"
|
||||
type="date"
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
@@ -364,8 +367,11 @@ ${msgRows}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||
<Text size="T300">To</Text>
|
||||
<Text as="label" htmlFor="export-to-date" size="T300">
|
||||
To
|
||||
</Text>
|
||||
<Input
|
||||
id="export-to-date"
|
||||
type="date"
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
|
||||
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
||||
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_ACL: ServerAclContent = {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: false,
|
||||
};
|
||||
|
||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||
|
||||
type ServerListProps = {
|
||||
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
|
||||
// Read current ACL from room state
|
||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
||||
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
||||
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
|
||||
|
||||
// Local draft state — initialised from current ACL
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
|
||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||
() => currentAcl.allow_ip_literals ?? false,
|
||||
() => currentAcl.allow_ip_literals,
|
||||
);
|
||||
|
||||
// Track whether there are unsaved changes
|
||||
const isDirty =
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
|
||||
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||
|
||||
// Save handler
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
id="allow-ip-literals"
|
||||
checked={allowIpLiterals}
|
||||
disabled={!canEdit}
|
||||
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
|
||||
// folds' Checkbox has no onChange; readOnly tells React the
|
||||
// click handler controls it (no "checked without onChange").
|
||||
readOnly
|
||||
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
|
||||
size="300"
|
||||
variant="Primary"
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<Modal500 requestClose={closeSettings}>
|
||||
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
|
||||
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
|
||||
// little wider than the 266px member/bookmark drawers because it hosts a media
|
||||
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
|
||||
// app's other drawers.
|
||||
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
|
||||
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
|
||||
// it becomes a full-screen fixed panel, matching the app's other drawers.
|
||||
export const MediaGalleryDrawer = style({
|
||||
width: toRem(320),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
overflow: 'hidden',
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
export const MembersDrawer = style({
|
||||
width: toRem(266),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const ThreadPanel = style({
|
||||
width: toRem(360),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { color, config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const ThreadsListPanel = style({
|
||||
width: toRem(340),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const WidgetsPanel = style({
|
||||
width: toRem(360),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -106,3 +106,19 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
|
||||
test('loadWidgetConsent tolerates missing storage', () => {
|
||||
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
||||
});
|
||||
|
||||
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
|
||||
const { auto, ask } = classifyWidgetCapabilities(
|
||||
[
|
||||
'm.space.child',
|
||||
'm.space.parent',
|
||||
'org.matrix.msc3401.call',
|
||||
'org.matrix.msc3401.call.member',
|
||||
'm.call.member',
|
||||
'io.lotus.voice_limit',
|
||||
'io.lotus.room_quality',
|
||||
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
|
||||
);
|
||||
assert.equal(auto.size, 0);
|
||||
assert.deepEqual(ask, []);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
|
||||
'm.room.third_party_invite',
|
||||
'im.vector.modular.widgets',
|
||||
'm.widget',
|
||||
// [Gitea #266] Space structure, call membership and Lotus room policy: a
|
||||
// widget could restructure a space, fake call membership or lift the
|
||||
// voice-channel limit, so these can't be granted even with consent.
|
||||
'm.space.child',
|
||||
'm.space.parent',
|
||||
'org.matrix.msc3401.call',
|
||||
'org.matrix.msc3401.call.member',
|
||||
'm.call.member',
|
||||
'io.lotus.voice_limit',
|
||||
'io.lotus.room_quality',
|
||||
]);
|
||||
|
||||
export type WidgetPermissionRequest = {
|
||||
@@ -137,8 +147,11 @@ export const classifyWidgetCapabilities = (
|
||||
*/
|
||||
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
||||
|
||||
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
|
||||
// approvals never apply to the next account on the same device.
|
||||
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
|
||||
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
||||
`lotus.widgetConsent.${roomId}.${widgetId}`;
|
||||
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
|
||||
|
||||
export const loadWidgetConsent = (
|
||||
roomId: string,
|
||||
|
||||
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
|
||||
key={mode}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={mode === selectedMode}
|
||||
aria-pressed={mode === selectedMode}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(mode)}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<Modal500 requestClose={closeSettings}>
|
||||
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<SpaceSettings initialPage={page} requestClose={closeSettings} />
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from '../../../client/initMatrix';
|
||||
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
||||
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
|
||||
import { clearSharedPayload } from '../../../swShare';
|
||||
import { SplashScreen } from '../../components/splash-screen';
|
||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||
@@ -172,6 +174,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
||||
// (translation, scheduled messages, recent searches/forwards/gifs/
|
||||
// stickers, nav paths) — wipe them on server-forced logout too.
|
||||
clearPlaintextCaches(mx?.getUserId() ?? undefined);
|
||||
// [Gitea #57] As on manual logout: no decrypted clip bytes via blob: URLs.
|
||||
clearSoundboardClipCache();
|
||||
// [Gitea #267] As on manual logout: drop files stashed by the share target.
|
||||
await clearSharedPayload().catch(() => undefined);
|
||||
// Remove only the session credential keys — NOT settings, drafts, and
|
||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||
|
||||
@@ -369,7 +369,7 @@ function InviteFilters({
|
||||
<Box gap="200">
|
||||
<Chip
|
||||
variant={isKnown ? 'Success' : 'Surface'}
|
||||
aria-selected={isKnown}
|
||||
aria-pressed={isKnown}
|
||||
outlined={!isKnown}
|
||||
onClick={() => onFilter(InviteFilter.Known)}
|
||||
before={isKnown && <Icon size="100" src={Icons.Check} />}
|
||||
@@ -385,7 +385,7 @@ function InviteFilters({
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={isUnknown ? 'Warning' : 'Surface'}
|
||||
aria-selected={isUnknown}
|
||||
aria-pressed={isUnknown}
|
||||
outlined={!isUnknown}
|
||||
onClick={() => onFilter(InviteFilter.Unknown)}
|
||||
before={isUnknown && <Icon size="100" src={Icons.Check} />}
|
||||
@@ -401,7 +401,7 @@ function InviteFilters({
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={isSpam ? 'Critical' : 'Surface'}
|
||||
aria-selected={isSpam}
|
||||
aria-pressed={isSpam}
|
||||
outlined={!isSpam}
|
||||
onClick={() => onFilter(InviteFilter.Spam)}
|
||||
before={isSpam && <Icon size="100" src={Icons.Check} />}
|
||||
|
||||
@@ -145,6 +145,11 @@ export function Share() {
|
||||
<Box direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Shared</Text>
|
||||
{/* [Gitea #267] Not everything that arrives here came from the
|
||||
user's share sheet; say so before they pick a room. */}
|
||||
<Text size="T200" priority="300">
|
||||
This came from another app or website. Check it before you send it.
|
||||
</Text>
|
||||
{shared.files.map((f) => (
|
||||
<Box key={f.name} alignItems="Center" gap="200">
|
||||
<Icon
|
||||
|
||||
@@ -217,7 +217,7 @@ export function SettingsTab() {
|
||||
</SidebarItemTooltip>
|
||||
<PresencePicker />
|
||||
{settingsOpen && (
|
||||
<Modal500 requestClose={() => setSettingsOpen(false)}>
|
||||
<Modal500 requestClose={() => setSettingsOpen(false)} label="Settings">
|
||||
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
|
||||
</Modal500>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ function UnverifiedIndicator() {
|
||||
</SidebarItem>
|
||||
)}
|
||||
{settings && (
|
||||
<Modal500 requestClose={closeSettings}>
|
||||
<Modal500 requestClose={closeSettings} label="Settings">
|
||||
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
|
||||
</Modal500>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,10 @@ const api = {
|
||||
: Object.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
|
||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
|
||||
await import('./plaintextCaches');
|
||||
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
|
||||
await import('../features/room/widgets/widgetUtils');
|
||||
|
||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||
store.clear();
|
||||
@@ -104,3 +107,17 @@ test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe
|
||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||
});
|
||||
|
||||
test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => {
|
||||
assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync');
|
||||
store.clear();
|
||||
const consent = widgetConsentKey('!room:server', 'widget-1');
|
||||
store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}');
|
||||
store.set('lotus.widgetConsentNotAKey', 'kept');
|
||||
removed.length = 0;
|
||||
|
||||
clearPlaintextCaches('@me:server');
|
||||
|
||||
assert.ok(removed.includes(consent), 'consent cleared');
|
||||
assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept');
|
||||
});
|
||||
|
||||
@@ -139,6 +139,31 @@ const clearAccountScopedFlags = (userId?: string): void => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #266] Remembered widget approvals (`lotus.widgetConsent.<room>.<widget>`,
|
||||
* widgetUtils.ts) would otherwise apply silently to the next account on this
|
||||
* device, and their keys list the previous account's rooms. Kept as a string
|
||||
* here so logout doesn't pull the widget code into the entry chunk; a test
|
||||
* checks it matches WIDGET_CONSENT_PREFIX.
|
||||
*/
|
||||
export const WIDGET_CONSENT_KEY_PREFIX = 'lotus.widgetConsent.';
|
||||
const clearWidgetConsents = (): void => {
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(localStorage);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
keys.forEach((key) => {
|
||||
if (!key.startsWith(WIDGET_CONSENT_KEY_PREFIX)) return;
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearPlaintextCaches = (userId?: string): void => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
@@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
clearWidgetConsents();
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { toRem } from 'folds';
|
||||
|
||||
/**
|
||||
* One width for every right-hand room panel (members, bookmarks, threads, a
|
||||
* thread, media gallery, widgets). Swapping one panel for another must not
|
||||
* resize the room view: when the widths differed, the header icons slid
|
||||
* sideways and a second click on the same spot hit a different button (#263).
|
||||
*/
|
||||
export const SIDE_PANEL_WIDTH = toRem(340);
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
|
||||
import {
|
||||
isValidServerPattern,
|
||||
globToRegExp,
|
||||
matchesAnyGlob,
|
||||
normalizeServerAcl,
|
||||
} from './serverAcl';
|
||||
|
||||
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
||||
for (const v of [
|
||||
@@ -42,3 +47,35 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
|
||||
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
|
||||
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
|
||||
});
|
||||
|
||||
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
|
||||
assert.deepEqual(normalizeServerAcl(undefined), {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
|
||||
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
|
||||
});
|
||||
|
||||
test('an explicit allow_ip_literals is kept', () => {
|
||||
assert.equal(
|
||||
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing or malformed lists become empty, non-strings are dropped', () => {
|
||||
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
|
||||
allow: ['a.example'],
|
||||
deny: [],
|
||||
allow_ip_literals: true,
|
||||
});
|
||||
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
|
||||
});
|
||||
|
||||
@@ -50,3 +50,29 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `m.room.server_acl` content with the spec's defaults filled in (#261).
|
||||
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
|
||||
* default to empty lists, but with no ACL event at all every server is allowed,
|
||||
* so the editor starts from `allow: ['*']` in that case.
|
||||
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
|
||||
*/
|
||||
export type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const stringList = (value: unknown): string[] | undefined =>
|
||||
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
|
||||
|
||||
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
|
||||
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
|
||||
return {
|
||||
allow: stringList(content.allow) ?? [],
|
||||
deny: stringList(content.deny) ?? [],
|
||||
allow_ip_literals:
|
||||
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { pushSessionToSW } from '../sw-session';
|
||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
||||
import { clearSharedPayload } from '../swShare';
|
||||
|
||||
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
||||
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||
@@ -146,6 +147,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
||||
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
||||
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
||||
clearSoundboardClipCache();
|
||||
// [Gitea #267] Files stashed by the share target stay in Cache Storage until
|
||||
// /share is opened; a shared photo must not outlive the logout.
|
||||
await clearSharedPayload().catch(() => undefined);
|
||||
// Remove only the session credential keys, preserving user preferences and
|
||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||
removeFallbackSession();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||
|
||||
export type {};
|
||||
@@ -249,6 +249,11 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
// share_target). Stash the form and bounce to the in-app /share page.
|
||||
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
||||
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
||||
// [Gitea #267] A cross-site form posting here is not a share: drop it.
|
||||
if (!isTrustedShareRequest(event.request.headers, self.location.origin)) {
|
||||
event.respondWith(Response.redirect(`${base}/`, 303));
|
||||
return;
|
||||
}
|
||||
event.respondWith(
|
||||
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
||||
);
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shareDraftText } from './swShare';
|
||||
import { isTrustedShareRequest, shareDraftText } from './swShare';
|
||||
|
||||
const base = { files: [], receivedAt: 0 };
|
||||
|
||||
@@ -25,3 +25,14 @@ test('shareDraftText drops a title/url the text already contains', () => {
|
||||
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
|
||||
assert.equal(shareDraftText(base), '');
|
||||
});
|
||||
|
||||
test('[#267] a share another website submitted is refused', () => {
|
||||
const app = 'https://chat.lotusguild.org';
|
||||
const h = (origin?: string) => new Headers(origin ? { Origin: origin } : {});
|
||||
assert.equal(isTrustedShareRequest(h(), app), true, 'no Origin (OS share sheet)');
|
||||
assert.equal(isTrustedShareRequest(h('null'), app), true, 'opaque origin (OS share sheet)');
|
||||
assert.equal(isTrustedShareRequest(h(app), app), true, 'the app itself');
|
||||
assert.equal(isTrustedShareRequest(h('https://evil.example'), app), false);
|
||||
assert.equal(isTrustedShareRequest(h('https://chat.lotusguild.org.evil.example'), app), false);
|
||||
assert.equal(isTrustedShareRequest(h('http://chat.lotusguild.org'), app), false, 'other scheme');
|
||||
});
|
||||
|
||||
@@ -20,6 +20,27 @@ export type SharePayload = {
|
||||
receivedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #267] Refuse a share that another website submitted: any site could
|
||||
* otherwise auto-submit a cross-site form here and drop attacker-chosen text
|
||||
* and files into the "Share to…" picker as if the user had shared them.
|
||||
*
|
||||
* The worker can't use `Sec-Fetch-Site`: fetch-metadata headers are added
|
||||
* after the service worker and never appear on `event.request` (checked in
|
||||
* Chromium). `Origin` does: a cross-site form carries the site's origin. The
|
||||
* OS share sheet's POST has no web initiator, so its Origin is `null` or
|
||||
* absent, and both stay accepted. A page can also produce `Origin: null`
|
||||
* (sandboxed frame, no-referrer), which is why /share also says the content
|
||||
* came from elsewhere.
|
||||
*/
|
||||
export const isTrustedShareRequest = (
|
||||
headers: Pick<Headers, 'get'>,
|
||||
selfOrigin: string,
|
||||
): boolean => {
|
||||
const origin = headers.get('Origin');
|
||||
return origin === null || origin === 'null' || origin === selfOrigin;
|
||||
};
|
||||
|
||||
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
||||
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
||||
const form = await request.formData();
|
||||
|
||||
Reference in New Issue
Block a user