Clarify receive requests and add retry countdowns and device unblock controls

This commit is contained in:
2026-10-14 12:22:07 +09:00
parent ff89daac8c
commit 3852c87e2e
16 changed files with 739 additions and 60 deletions

View File

@@ -281,7 +281,23 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
code = 'INVALID_REQUEST';
message = '요청 형식이나 크기를 확인해 주세요.';
}
reply.status(status).send({ code, message, requestId: req.id });
const retryAfterSeconds =
err instanceof ApiError && err.retryAt
? Math.max(1, Math.ceil((err.retryAt - Date.now()) / 1000))
: undefined;
if (retryAfterSeconds) reply.header('Retry-After', retryAfterSeconds);
reply.status(status).send({
code,
message,
requestId: req.id,
...(retryAfterSeconds
? {
retryAfterSeconds,
retryAt: (err as ApiError).retryAt,
limitScope: (err as ApiError).limitScope,
}
: {}),
});
});
const setCookie = (reply: any, token: string, expiresAt: number) =>
reply.setCookie('hop_guest', token, {
@@ -354,6 +370,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
service.patchDevice(guest, devicePatch.parse(req.body));
return { device: service.device(guest.id) };
});
app.get('/api/device/blocks', async (req) => ({ blocks: service.listBlocks(auth(req)) }));
app.delete('/api/device/blocks/:id', async (req) => {
service.unblock(auth(req), id(req));
return { ok: true };
});
app.post('/api/device/reset', async (req, reply) => {
const guest = auth(req);
service.reset(guest);

View File

@@ -106,9 +106,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
maxTextBytes: num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) * 1024,
maxItems: num('MAX_ITEMS_PER_TRANSFER', 20),
urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)),
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5),
ipRate: num('IP_REQUESTS_PER_MINUTE', 20),
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2),
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 30),
ipRate: num('IP_REQUESTS_PER_MINUTE', 120),
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 10),
rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000,
uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)),
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,

View File

@@ -6,6 +6,8 @@ export class ApiError extends Error {
public status: number,
public code: string,
message: string,
public retryAt?: number,
public limitScope?: 'pair' | 'sender' | 'ip',
) {
super(message);
}
@@ -77,13 +79,37 @@ export function publicIp(ip: string | null): boolean {
export class Limiter {
private windows = new Map<string, { count: number; until: number }>();
hit(key: string, max: number, windowMs = 60000) {
this.hitMany([{ key, max }], windowMs);
}
hitMany(limits: { key: string; max: number }[], windowMs = 60000) {
const now = Date.now();
let w = this.windows.get(key);
if (!w || w.until <= now) {
w = { count: 0, until: now + windowMs };
this.windows.set(key, w);
const windows = limits.map(({ key, max }) => {
const previous = this.windows.get(key);
const window =
previous && previous.until > now ? previous : { count: 0, until: now + windowMs };
return { key, max, window };
});
const exhausted = windows
.filter(({ max, window }) => window.count >= max)
.sort((a, b) => b.window.until - a.window.until)[0];
if (exhausted) {
const scope = exhausted.key.startsWith('request-pair:')
? 'pair'
: exhausted.key.startsWith('request-ip:')
? 'ip'
: 'sender';
throw new ApiError(
429,
'RATE_LIMIT',
'요청이 많아요. 잠시 후 다시 시도해 주세요.',
exhausted.window.until,
scope,
);
}
for (const { key, window } of windows) {
window.count++;
this.windows.set(key, window);
}
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
}
prune() {
const now = Date.now();

View File

@@ -14,7 +14,7 @@ import type { DB } from './db/index.js';
import * as s from './db/schema.js';
import type { Config } from './config.js';
import type { Presence } from './presence.js';
import { fail, hash, hmac, randomToken, Limiter } from './security.js';
import { fail, hash, hmac, randomToken, Limiter, ApiError } from './security.js';
import { type Device, type Item, type Peer, type Space, type TransferRequest } from '@hop/shared';
export type Guest = typeof s.guests.$inferSelect;
export type RequestRow = typeof s.requests.$inferSelect;
@@ -599,9 +599,6 @@ export class Service {
input.files?.some((f) => f.size > this.cfg.maxFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
this.limiter.hit(`request-ip:${ip}`, this.cfg.ipRate);
this.limiter.hit(`request-guest:${g.id}`, this.cfg.sessionRate);
this.limiter.hit(`request-pair:${g.id}:${target!.guestId}`, this.cfg.pairRate);
const rejected = this.db
.select()
.from(s.requests)
@@ -613,9 +610,16 @@ export class Service {
gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs),
),
)
.orderBy(desc(s.requests.rejectedAt))
.get();
if (rejected)
fail(429, 'REJECTION_COOLDOWN', '상대가 거절했습니다. 잠시 후 다시 시도해 주세요.');
throw new ApiError(
429,
'REJECTION_COOLDOWN',
'상대가 거절했어요. 잠시 후 다시 시도해 주세요.',
rejected.rejectedAt! + this.cfg.rejectCooldownMs,
'pair',
);
const active = this.db
.select()
.from(s.requests)
@@ -629,6 +633,11 @@ export class Service {
)
.get();
if (active) fail(409, 'TRANSFER_ACTIVE', '진행 중인 전송을 먼저 완료하거나 취소해 주세요.');
this.limiter.hitMany([
{ key: `request-ip:${ip}`, max: this.cfg.ipRate },
{ key: `request-guest:${g.id}`, max: this.cfg.sessionRate },
{ key: `request-pair:${g.id}:${target!.guestId}`, max: this.cfg.pairRate },
]);
const now = Date.now(),
id = randomUUID();
this.db
@@ -752,6 +761,37 @@ export class Service {
this.notifyRequests();
this.refresh();
}
listBlocks(g: Guest) {
return this.db
.select({ deviceId: s.devices.id, name: s.devices.name, code: s.devices.code })
.from(s.blocks)
.innerJoin(s.devices, eq(s.blocks.targetGuestId, s.devices.guestId))
.where(eq(s.blocks.guestId, g.id))
.all();
}
unblock(g: Guest, targetDeviceId: string) {
const target = this.deviceGuest(targetDeviceId);
if (!target) return;
this.db.transaction(() => {
const removed = this.db
.delete(s.blocks)
.where(and(eq(s.blocks.guestId, g.id), eq(s.blocks.targetGuestId, target.guestId)))
.run();
if (removed.changes)
this.db
.update(s.requests)
.set({ rejectedAt: null })
.where(
and(
eq(s.requests.senderGuestId, target.guestId),
eq(s.requests.recipientGuestId, g.id),
eq(s.requests.status, 'rejected'),
),
)
.run();
});
this.refresh();
}
complete(g: Guest, id: string, input: CompleteInput) {
const row = this.request(g, id);
if (row.kind === 'file')

View File

@@ -0,0 +1,121 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './helpers.js';
import { Limiter, ApiError } from '../src/security.js';
test('rate windows report their deadline; denied retries do not charge other quotas', (t) => {
let now = 1000;
t.mock.method(Date, 'now', () => now);
const limiter = new Limiter();
const limits = [
{ key: 'request-guest:a', max: 2 },
{ key: 'request-pair:a:b', max: 1 },
];
limiter.hitMany(limits);
now += 5000;
for (let i = 0; i < 20; i++)
assert.throws(
() => limiter.hitMany(limits),
(err) => {
assert(err instanceof ApiError);
assert.equal(err.status, 429);
assert.equal(err.retryAt, 61000);
assert.equal(err.limitScope, 'pair');
return true;
},
);
limiter.hitMany([
{ key: 'request-guest:a', max: 2 },
{ key: 'request-pair:a:c', max: 1 },
]);
assert.throws(() => limiter.hit('request-guest:a', 2));
now = 61000;
assert.doesNotThrow(() => limiter.hitMany(limits));
});
test('transfer limit returns Retry-After and cooldown metadata without creating a request', async () => {
const f = await fixture();
try {
f.cfg.pairRate = 2;
const a = await f.create(),
b = await f.create();
await f.space(a, b);
for (let i = 0; i < 2; i++) {
const response = await f.request(a, b);
assert.equal(response.statusCode, 200);
await f.call(a, `/transfer-requests/${response.json().requestId}/cancel`, 'POST', {});
}
const limited = await f.request(a, b);
assert.equal(limited.statusCode, 429);
assert.equal(limited.json().code, 'RATE_LIMIT');
assert.equal(limited.json().limitScope, 'pair');
assert(Number(limited.headers['retry-after']) > 0);
assert(Number(limited.headers['retry-after']) <= 60);
assert.equal(Number(limited.headers['retry-after']), limited.json().retryAfterSeconds);
assert(limited.json().retryAt > Date.now());
const next = await f.request(a, b);
assert.equal(next.json().retryAt, limited.json().retryAt);
assert.equal(
(await f.call(b, '/device/snapshot')).json().requests.filter((r) => r.status === 'pending')
.length,
0,
);
} finally {
await f.close();
}
});
test('rejection cooldown reports time and does not use a successful-transfer quota', async () => {
const f = await fixture();
try {
f.cfg.sessionRate = 2;
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
const request = await f.request(a, b);
await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {});
for (let i = 0; i < 4; i++) {
const denied = await f.request(a, b);
assert.equal(denied.statusCode, 429);
assert.equal(denied.json().code, 'REJECTION_COOLDOWN');
assert.equal(denied.json().limitScope, 'pair');
assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290);
}
assert.equal((await f.request(a, c)).statusCode, 200);
} finally {
await f.close();
}
});
test('block list is private; unblock only removes the current device block and never revives a request', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/block`, 'POST', {});
assert.equal((await f.call(null, '/device/blocks')).statusCode, 401);
assert.deepEqual((await f.call(a, '/device/blocks')).json().blocks, []);
const blocks = (await f.call(b, '/device/blocks')).json().blocks;
assert.equal(blocks.length, 1);
assert.equal(blocks[0].deviceId, a.deviceId);
assert.deepEqual(Object.keys(blocks[0]).sort(), ['code', 'deviceId', 'name']);
await f.call(c, `/device/blocks/${a.deviceId}`, 'DELETE');
assert.equal((await f.call(b, '/device/blocks')).json().blocks.length, 1);
assert.equal((await f.request(a, b)).statusCode, 403);
assert.equal((await f.call(b, `/device/blocks/${a.deviceId}`, 'DELETE')).statusCode, 200);
assert.deepEqual((await f.call(b, '/device/blocks')).json().blocks, []);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, crypto.randomUUID()))
.statusCode,
409,
);
assert.equal((await f.request(a, b)).statusCode, 200);
} finally {
await f.close();
}
});

View File

@@ -65,6 +65,7 @@ import {
items,
spaces,
error,
transferCooldown,
bootstrap,
api,
refresh,
@@ -94,6 +95,35 @@ const tab = ref('devices'),
invite = ref(null),
qr = ref(''),
now = ref(Date.now());
const blockedDevices = ref([]),
confirmBlockId = ref(null);
const retrySeconds = computed(() =>
transferCooldown.value.targetDeviceId &&
transferCooldown.value.targetDeviceId !== selected.value?.id
? 0
: Math.max(
0,
Math.ceil((transferCooldown.value.until - Math.max(now.value, Date.now())) / 1000),
),
);
const modalTitle = computed(() =>
modal.value === 'connect'
? connectTitle.value
: modal.value === 'about'
? '서비스 정보'
: modal.value === 'blocks'
? '차단한 기기'
: '내 기기 설정',
);
function contentLabel(kind) {
return kind === 'file' ? '파일' : kind === 'text' ? '텍스트' : '링크';
}
function transferSize(bytes) {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
if (bytes >= 1e5) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1)} KB`;
return '1 KB 미만';
}
const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
const validJoinCode = computed(() =>
/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value),
@@ -186,6 +216,10 @@ async function receiveAction(r, action) {
await act(async () => {
try {
await requestAction(r.id, action);
if (action === 'block') {
confirmBlockId.value = null;
notify('기기를 차단했어요. 내 기기 설정에서 해제할 수 있어요.');
}
} catch (error) {
if (action === 'accept') receiver.abort(r.id);
throw error;
@@ -563,6 +597,20 @@ async function openDeviceSettings() {
if (!matchMedia('(max-width: 639px)').matches)
document.querySelector('.device-settings-form input')?.focus();
}
async function openBlockedDevices() {
await act(async () => {
blockedDevices.value = (await api('/device/blocks')).blocks;
modal.value = 'blocks';
});
}
async function unblockDevice(peer) {
await act(async () => {
await api(`/device/blocks/${peer.deviceId}`, 'DELETE');
blockedDevices.value = (await api('/device/blocks')).blocks;
await refresh();
notify('차단을 해제했어요.');
});
}
async function saveName() {
await act(async () => {
await patchDevice({ name: nameDraft.value });
@@ -879,20 +927,37 @@ async function spaceAction(space, action, memberId) {
aria-label="전송 수락 요청"
tabindex="-1"
>
<div class="incoming-icon"><ArrowDownLeft :size="22" /></div>
<div class="incoming-info">
<strong
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
>
<p>
{{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} ·
{{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
</p>
<div class="consent-heading">
<div class="incoming-icon" aria-hidden="true">
<FileIcon v-if="r.kind === 'file'" :size="28" /><ClipboardPaste
v-else-if="r.kind === 'text'"
:size="28"
/><Link v-else :size="28" />
</div>
<div class="incoming-info">
<h2 class="consent-kind">{{ contentLabel(r.kind) }} 전송 요청</h2>
<strong
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
>
<p>
<template v-if="r.kind === 'file'"
>{{ r.count }}개 ·
<template v-if="r.count > 1"
>총 {{ transferSize(r.byteSize) }} ·
</template></template
>
<span
>{{
Math.max(0, Math.ceil((r.expiresAt - Math.max(now, Date.now())) / 1000))
}}초 남음</span
>
</p>
</div>
</div>
<ul v-if="r.files" class="request-files">
<li v-for="file in r.files" :key="file.id">
{{ file.name }} · {{ size(file.size) }}
<span class="request-file-name">{{ file.name }}</span
><span class="request-file-size">{{ transferSize(file.size) }}</span>
</li>
</ul>
<div class="incoming-actions">
@@ -903,16 +968,31 @@ async function spaceAction(space, action, memberId) {
>
<Check :size="16" /> 수락</button
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
거절</button
><button
class="text-button muted"
:disabled="busy"
@click="receiveAction(r, 'block')"
>
차단
거절
</button>
</div>
<div class="consent-footer">
<template v-if="confirmBlockId === r.id"
><span>이 기기를 차단할까요?</span
><button class="text-button" :disabled="busy" @click="confirmBlockId = null">
취소</button
><button
class="text-button block-confirm"
:disabled="busy"
@click="receiveAction(r, 'block')"
>
차단하기
</button></template
>
<button
v-else
class="text-button block-device"
:disabled="busy"
@click="confirmBlockId = r.id"
>
이 기기 차단
</button>
</div>
<p class="request-note">기기 이름은 신원이 인증된 표시가 아닙니다.</p>
</article>
</div>
</Teleport>
@@ -1115,6 +1195,7 @@ async function spaceAction(space, action, memberId) {
:clipboard-key="selectionEpoch"
:read-clipboard-on-selection="true"
:busy="busy"
:retry-seconds="retrySeconds"
:transfer="currentTransfer"
:progress="
Object.entries(visibleUploadProgress)
@@ -1147,14 +1228,7 @@ async function spaceAction(space, action, memberId) {
</button>
</div>
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
<section
class="modal"
role="dialog"
aria-modal="true"
:aria-label="
modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정'
"
>
<section class="modal" role="dialog" aria-modal="true" :aria-label="modalTitle">
<header>
<div class="connect-dialog-title">
<button
@@ -1167,13 +1241,7 @@ async function spaceAction(space, action, memberId) {
<ArrowLeft :size="20" />
</button>
<h2>
{{
modal === 'connect'
? connectTitle
: modal === 'about'
? '서비스 정보'
: '내 기기 설정'
}}
{{ modalTitle }}
</h2>
</div>
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
@@ -1278,6 +1346,21 @@ async function spaceAction(space, action, memberId) {
</footer>
</div>
</template>
<template v-else-if="modal === 'blocks'">
<p v-if="!blockedDevices.length" class="empty-blocks">차단한 기기가 없어요.</p>
<ul v-else class="blocked-device-list">
<li v-for="peer in blockedDevices" :key="peer.deviceId">
<div>
<strong>{{ peer.name }}</strong
><small>#{{ peer.code }}</small>
</div>
<button class="button small" :disabled="busy" @click="unblockDevice(peer)">
차단 해제
</button>
</li>
</ul>
<button class="text-button" @click="modal = 'device'">내 기기 설정으로 돌아가기</button>
</template>
<template v-else
><form class="name-form device-settings-form" @submit.prevent="saveName">
<label>
@@ -1296,6 +1379,10 @@ async function spaceAction(space, action, memberId) {
/> </label
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
</form>
<div class="device-blocks-setting">
<span>차단한 기기</span
><button class="button small" :disabled="busy" @click="openBlockedDevices">관리</button>
</div>
</template>
</section>
</div>

View File

@@ -19,6 +19,7 @@ const props = defineProps({
targetKey: String,
clipboardKey: Number,
busy: Boolean,
retrySeconds: { type: Number, default: 0 },
disabled: Boolean,
transfer: Object,
progress: Array,
@@ -290,7 +291,7 @@ function submit() {
emit('cancel');
return;
}
if (!ready.value || !mutable.value) return;
if (!ready.value || !mutable.value || props.retrySeconds > 0) return;
if (source.value === 'url')
emit(
'clipboard',
@@ -514,10 +515,21 @@ function size(value) {
<button
class="button primary content-submit"
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
:disabled="busy || disabled || ((!transfer || retryable) && !ready)"
:disabled="
busy || disabled || (!transfer && retrySeconds > 0) || ((!transfer || retryable) && !ready)
"
@click="submit"
>
{{ retryable ? '다시 전송' : transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
{{
!transfer && retrySeconds > 0
? `${retrySeconds}초 후 전송 가능`
: retryable
? '다시 전송'
: transfer
? '전송 취소'
: saveMode
? '저장하기'
: '전송하기'
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
</button>
<div v-if="retryable" class="content-switches">

View File

@@ -26,6 +26,7 @@ export const device = ref(null),
discoveryEnabled = ref(false),
error = ref('');
export const pendingPeers = ref([]);
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
@@ -60,11 +61,28 @@ export async function api(path, method = 'GET', body, key, signal) {
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
if (!response.ok)
if (!response.ok) {
const retryAfterSeconds = Number(
payload.retryAfterSeconds ?? response.headers.get('Retry-After'),
);
if (
response.status === 429 &&
retryAfterSeconds > 0 &&
Number.isFinite(retryAfterSeconds) &&
method === 'POST' &&
(path === '/transfer-requests' || /^\/vault\/[^/]+\/send$/.test(path))
) {
transferCooldown.value = {
until: Date.now() + Math.ceil(retryAfterSeconds) * 1000,
targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null,
};
}
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
status: response.status,
code: payload.code,
retryAfterSeconds,
});
}
return payload;
}
export async function bootstrap() {
@@ -154,9 +172,13 @@ export function showError(err) {
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
: raw;
error.value = message;
const displayed =
err?.retryAfterSeconds > 0
? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.`
: message;
error.value = displayed;
errorTimer = setTimeout(() => {
if (error.value === message) error.value = '';
if (error.value === displayed) error.value = '';
}, 5000);
}
export const refresh = createRefreshQueue(async () => {

View File

@@ -2419,6 +2419,123 @@ a.button {
.consent-card .text-button {
color: #666;
}
.consent-heading {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.consent-card .incoming-icon {
width: 52px;
height: 52px;
flex: 0 0 52px;
}
.consent-kind {
font-size: 22px;
line-height: 1.3;
font-weight: 700;
margin: 0 0 8px;
}
.consent-card .incoming-info strong {
font-size: 15px;
overflow-wrap: anywhere;
}
.consent-card .incoming-info small {
font-size: 12px;
}
.consent-card .incoming-info p {
font-size: 12px;
}
.consent-card .request-files {
width: 100%;
margin: 6px 0;
padding: 0;
list-style: none;
max-height: 160px;
}
.consent-card .request-files li {
display: flex;
align-items: baseline;
gap: 12px;
font-size: 14px;
padding: 6px 0;
}
.request-file-name {
min-width: 0;
flex: 1;
overflow-wrap: anywhere;
}
.request-file-size {
color: #666;
font-size: 12px;
white-space: nowrap;
}
.consent-card .incoming-actions {
display: grid;
grid-template-columns: 2fr 1fr;
padding: 0;
gap: 10px;
margin-top: 10px;
}
.consent-card .incoming-actions .button {
width: 100%;
min-width: 0;
min-height: 48px;
font-size: 14px;
justify-content: center;
}
.consent-footer {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin-top: 0;
font-size: 12px;
}
.consent-footer .text-button {
font-size: 12px;
padding: 0;
min-height: 44px;
}
.consent-footer .block-confirm {
color: #a33;
}
.device-blocks-setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 28px;
font-size: 14px;
}
.blocked-device-list {
margin: 0 0 20px;
padding: 0;
list-style: none;
}
.blocked-device-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.blocked-device-list li > div {
min-width: 0;
}
.blocked-device-list strong {
display: block;
overflow-wrap: anywhere;
}
.blocked-device-list small {
color: var(--muted);
}
.empty-blocks {
margin: 0 0 20px;
color: var(--muted);
}
.composer-transfer-state {
margin: 16px 0;
font-size: 13px;