CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m56s
- #456 Copy pass: sentence case for every setting title and section, plainer names ("Hide joins and leaves", "Link previews", "Emoji style: Twemoji", "Rejoin calls after a restart", "Also use the browser's noise suppression", "Push-to-talk key"...) and grammar fixes ("when messages arrive", "Play a sound when a new message arrives", blocked users "Enter their Matrix ID"). Labels only: no setting is renamed in storage. - #455 One theme control: Theme: System / Light / Dark. System shows the light and dark variant pickers, Light or Dark one picker of that kind. Replaces the System switch plus a Theme dropdown that was greyed out whenever the switch was on. Uses the existing settings (no migration); picking a variant also remembers it for System. - #457 Remove the "Block Messages" stub from Notifications; the GIF picker row only appears in builds with a GIF key. - #458 (part 1) "Export/Import Messages Data" -> "Export/Import encryption keys", with a sentence on when you need it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { useAtom } from 'jotai';
|
|
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
|
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
|
import { SequenceCard } from '../../../components/sequence-card';
|
|
import { SequenceCardStyle } from '../styles.css';
|
|
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
|
|
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
|
|
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
|
|
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
|
import { VerifyOtherDeviceTile } from './Verification';
|
|
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
|
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
|
import { withSearchParam } from '../../../pages/pathUtils';
|
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
|
import { SettingTile } from '../../../components/setting-tile';
|
|
import { preselectSignOutAtom } from '../../../state/staleSessions';
|
|
import { staleSessions } from '../../../utils/securityNudge';
|
|
|
|
type OtherDevicesProps = {
|
|
devices: IMyDevice[];
|
|
refreshDeviceList: () => Promise<void>;
|
|
showVerification?: boolean;
|
|
};
|
|
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
|
const mx = useMatrixClient();
|
|
const crypto = mx.getCrypto();
|
|
const authMetadata = useAuthMetadata();
|
|
const accountManagementActions = useAccountManagementActions();
|
|
|
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
|
|
|
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
|
|
// sign-out (one confirm below). OIDC accounts sign out on the provider's
|
|
// dashboard instead, so nothing is ticked there.
|
|
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
|
|
useEffect(() => {
|
|
if (preselect.length === 0) return;
|
|
if (!authMetadata) {
|
|
const present = new Set(devices.map((d) => d.device_id));
|
|
setDeleted(new Set(preselect.filter((id) => present.has(id))));
|
|
}
|
|
setPreselect([]);
|
|
}, [preselect, setPreselect, devices, authMetadata]);
|
|
|
|
const staleIds = new Set(
|
|
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
|
|
);
|
|
|
|
const handleDashboardOIDC = useCallback(() => {
|
|
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
|
if (!authUrl) return;
|
|
|
|
window.open(
|
|
withSearchParam(authUrl, {
|
|
action: accountManagementActions.sessionsList,
|
|
}),
|
|
'_blank',
|
|
'noopener,noreferrer',
|
|
);
|
|
}, [authMetadata, accountManagementActions]);
|
|
|
|
const handleDeleteOIDC = useCallback(
|
|
(deviceId: string) => {
|
|
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
|
if (!authUrl) return;
|
|
|
|
window.open(
|
|
withSearchParam(authUrl, {
|
|
action: accountManagementActions.sessionEnd,
|
|
device_id: deviceId,
|
|
}),
|
|
'_blank',
|
|
'noopener,noreferrer',
|
|
);
|
|
},
|
|
[authMetadata, accountManagementActions],
|
|
);
|
|
|
|
const handleToggleDelete = useCallback((deviceId: string) => {
|
|
setDeleted((deviceIds) => {
|
|
const newIds = new Set(deviceIds);
|
|
if (newIds.has(deviceId)) {
|
|
newIds.delete(deviceId);
|
|
} else {
|
|
newIds.add(deviceId);
|
|
}
|
|
return newIds;
|
|
});
|
|
}, []);
|
|
|
|
const [deleteState, setDeleteState] = useState<AsyncState<void, MatrixError>>({
|
|
status: AsyncStatus.Idle,
|
|
});
|
|
|
|
const deleteDevices = useAsync(
|
|
useCallback(
|
|
async (authDict?: AuthDict) => {
|
|
await mx.deleteMultipleDevices(Array.from(deleted), authDict);
|
|
},
|
|
[mx, deleted],
|
|
),
|
|
useCallback(
|
|
(state: typeof deleteState) => {
|
|
if (state.status === AsyncStatus.Success) {
|
|
setDeleted(new Set());
|
|
refreshDeviceList();
|
|
}
|
|
setDeleteState(state);
|
|
},
|
|
[refreshDeviceList],
|
|
),
|
|
);
|
|
const [authData, deleteError] = useUIAMatrixError(
|
|
deleteState.status === AsyncStatus.Error ? deleteState.error : undefined,
|
|
);
|
|
const deleting = deleteState.status === AsyncStatus.Loading || authData !== undefined;
|
|
|
|
const handleCancelDelete = () => setDeleted(new Set());
|
|
const handleCancelAuth = useCallback(() => {
|
|
setDeleteState({ status: AsyncStatus.Idle });
|
|
}, []);
|
|
|
|
return devices.length > 0 ? (
|
|
<>
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Others</Text>
|
|
{authMetadata && (
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
<SettingTile
|
|
title="Device dashboard"
|
|
description="Manage your devices on OIDC dashboard."
|
|
after={
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
outlined
|
|
onClick={handleDashboardOIDC}
|
|
>
|
|
<Text size="B300">Open</Text>
|
|
</Button>
|
|
}
|
|
/>
|
|
</SequenceCard>
|
|
)}
|
|
{staleIds.size > 0 && (
|
|
<Text size="T200" priority="300">
|
|
{staleIds.size === 1
|
|
? "1 session hasn't been used in 3 months or more (marked Inactive)."
|
|
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
|
|
Sign out the ones you no longer use.
|
|
</Text>
|
|
)}
|
|
{[...devices]
|
|
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
|
|
.sort((d1, d2) => {
|
|
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
|
|
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
|
|
if (s1 !== s2) return s1 - s2;
|
|
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
|
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
|
})
|
|
.map((device) => (
|
|
<SequenceCard
|
|
key={device.device_id}
|
|
className={SequenceCardStyle}
|
|
variant={deleted.has(device.device_id) ? 'Critical' : 'SurfaceVariant'}
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
{staleIds.has(device.device_id) && (
|
|
<Text size="L400" style={{ color: 'inherit' }}>
|
|
Inactive for 3+ months
|
|
</Text>
|
|
)}
|
|
<DeviceTile
|
|
device={device}
|
|
deleted={deleted.has(device.device_id)}
|
|
refreshDeviceList={refreshDeviceList}
|
|
disabled={deleting}
|
|
options={
|
|
authMetadata ? (
|
|
<DeviceDeleteBtn
|
|
deviceId={device.device_id}
|
|
deleted={false}
|
|
onDeleteToggle={handleDeleteOIDC}
|
|
/>
|
|
) : (
|
|
<DeviceDeleteBtn
|
|
deviceId={device.device_id}
|
|
deleted={deleted.has(device.device_id)}
|
|
onDeleteToggle={handleToggleDelete}
|
|
disabled={deleting}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
{showVerification && crypto && (
|
|
<DeviceVerificationStatus
|
|
crypto={crypto}
|
|
userId={mx.getSafeUserId()}
|
|
deviceId={device.device_id}
|
|
>
|
|
{(status) =>
|
|
status === VerificationStatus.Unverified && (
|
|
<VerifyOtherDeviceTile crypto={crypto} deviceId={device.device_id} />
|
|
)
|
|
}
|
|
</DeviceVerificationStatus>
|
|
)}
|
|
</SequenceCard>
|
|
))}
|
|
</Box>
|
|
{deleted.size > 0 && (
|
|
<Menu
|
|
style={{
|
|
position: 'sticky',
|
|
padding: config.space.S200,
|
|
paddingLeft: config.space.S400,
|
|
bottom: config.space.S400,
|
|
left: config.space.S400,
|
|
right: 0,
|
|
zIndex: 1,
|
|
}}
|
|
variant="Critical"
|
|
>
|
|
<Box alignItems="Center" gap="400">
|
|
<Box grow="Yes" direction="Column">
|
|
{deleteError ? (
|
|
<Text size="T200">
|
|
<b>Failed to logout devices! Please try again. {deleteError.message}</b>
|
|
</Text>
|
|
) : (
|
|
<Text size="T200">
|
|
<b>Logout from selected devices. ({deleted.size} selected)</b>
|
|
</Text>
|
|
)}
|
|
{authData && (
|
|
<ActionUIAFlowsLoader
|
|
authData={authData}
|
|
unsupported={() => (
|
|
<Text size="T200">
|
|
Authentication steps to perform this action are not supported by client.
|
|
</Text>
|
|
)}
|
|
>
|
|
{(ongoingFlow) => (
|
|
<ActionUIA
|
|
authData={authData}
|
|
ongoingFlow={ongoingFlow}
|
|
action={deleteDevices}
|
|
onCancel={handleCancelAuth}
|
|
/>
|
|
)}
|
|
</ActionUIAFlowsLoader>
|
|
)}
|
|
</Box>
|
|
<Box shrink="No" gap="200">
|
|
<Button
|
|
size="300"
|
|
variant="Critical"
|
|
fill="None"
|
|
radii="300"
|
|
disabled={deleting}
|
|
onClick={handleCancelDelete}
|
|
>
|
|
<Text size="B300">Cancel</Text>
|
|
</Button>
|
|
<Button
|
|
size="300"
|
|
variant="Critical"
|
|
radii="300"
|
|
disabled={deleting}
|
|
before={deleting && <Spinner variant="Critical" fill="Solid" size="100" />}
|
|
onClick={() => deleteDevices()}
|
|
>
|
|
<Text size="B300">Logout</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Menu>
|
|
)}
|
|
</>
|
|
) : null;
|
|
}
|