feat(api-access): add drag-and-drop provider reordering
This commit is contained in:
1 parent
dbd97d862f
commit
813fbf97a8
8 files changed
+253
-14
No files matched your search
@@ -5,6 +5,9 @@
|
||||
"": {
|
||||
"name": "cpa-gui",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -59,6 +62,14 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
@@ -277,6 +288,8 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -652,6 +652,7 @@ export const jaOverrides = {
|
||||
'apiAccess.notice.deleted': '接続を削除しました',
|
||||
'apiAccess.notice.enabled': '接続を有効にしました',
|
||||
'apiAccess.notice.disabled': '接続を無効にしました',
|
||||
'apiAccess.notice.reordered': 'プロバイダーの順序を更新しました',
|
||||
'apiAccess.title': 'API 接続',
|
||||
'apiAccess.count': '接続 {count} 件',
|
||||
'apiAccess.add': '追加',
|
||||
@@ -673,6 +674,7 @@ export const jaOverrides = {
|
||||
'apiAccess.status.disabled': '無効',
|
||||
'apiAccess.status.enabled': '有効',
|
||||
'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ',
|
||||
'apiAccess.dragHandle': '「{remark}」をドラッグして並べ替えます。キーボードでは Space の後に矢印キーを使用します',
|
||||
'apiAccess.health.action': 'ヘルスチェック',
|
||||
'apiAccess.health.title': 'モデルヘルスチェック',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
|
||||
@@ -652,6 +652,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.notice.deleted': 'Connection deleted',
|
||||
'apiAccess.notice.enabled': 'Connection enabled',
|
||||
'apiAccess.notice.disabled': 'Connection disabled',
|
||||
'apiAccess.notice.reordered': 'Provider order updated',
|
||||
'apiAccess.title': 'API Access',
|
||||
'apiAccess.count': '{count} connections',
|
||||
'apiAccess.add': 'Add',
|
||||
@@ -673,6 +674,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.status.disabled': 'Disabled',
|
||||
'apiAccess.status.enabled': 'Enabled',
|
||||
'apiAccess.toggleAria': '{remark} connection {action} switch',
|
||||
'apiAccess.dragHandle': 'Drag to reorder {remark}; with a keyboard, press Space then use the arrow keys',
|
||||
'apiAccess.health.action': 'Health Check',
|
||||
'apiAccess.health.title': 'Model Health Check',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
|
||||
@@ -650,6 +650,7 @@ export const zhCN = {
|
||||
'apiAccess.notice.deleted': '接入已删除',
|
||||
'apiAccess.notice.enabled': '接入已启用',
|
||||
'apiAccess.notice.disabled': '接入已停用',
|
||||
'apiAccess.notice.reordered': '供应商排序已更新',
|
||||
'apiAccess.title': 'API 接入',
|
||||
'apiAccess.count': '{count} 个接入',
|
||||
'apiAccess.add': '新增',
|
||||
@@ -671,6 +672,7 @@ export const zhCN = {
|
||||
'apiAccess.status.disabled': '已停用',
|
||||
'apiAccess.status.enabled': '已启用',
|
||||
'apiAccess.toggleAria': '{remark} 接入{action}开关',
|
||||
'apiAccess.dragHandle': '拖动“{remark}”调整排序;键盘可按空格后使用方向键',
|
||||
'apiAccess.health.action': '健康检测',
|
||||
'apiAccess.health.title': '模型健康检测',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
|
||||
+167
-8
@@ -1,9 +1,35 @@
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
sortableKeyboardCoordinates,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS as DndCss } from '@dnd-kit/utilities';
|
||||
import {
|
||||
Check,
|
||||
Edit3,
|
||||
Filter,
|
||||
GripVertical,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -79,6 +105,57 @@ type ProviderRow = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const providerDragId = (row: Pick<ProviderRow, 'section' | 'index'>) =>
|
||||
`${row.section}:${row.index}`;
|
||||
|
||||
function SortableProviderRow({
|
||||
row,
|
||||
disabled,
|
||||
dragLabel,
|
||||
isDragOver,
|
||||
children,
|
||||
}: {
|
||||
row: ProviderRow;
|
||||
disabled: boolean;
|
||||
dragLabel: string;
|
||||
isDragOver: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: providerDragId(row), disabled });
|
||||
const style: CSSProperties = {
|
||||
transform: DndCss.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`real-provider-row${isDragging ? ' dragging' : ''}${isDragOver && !isDragging ? ' drag-over' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet provider-drag-handle"
|
||||
disabled={disabled}
|
||||
aria-label={dragLabel}
|
||||
title={dragLabel}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={17} aria-hidden="true" />
|
||||
</button>
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type ProviderSaveResult =
|
||||
| { saved: true }
|
||||
| { saved: false; error: string; target: 'form' | 'models' };
|
||||
@@ -610,7 +687,7 @@ const providerIdentityMatches = (
|
||||
);
|
||||
};
|
||||
|
||||
type ProviderRecordIdentity = Pick<
|
||||
export type ProviderRecordIdentity = Pick<
|
||||
ProviderRow,
|
||||
'section' | 'index' | 'name' | 'apiKey' | 'baseUrl'
|
||||
>;
|
||||
@@ -640,6 +717,35 @@ export const resolveProviderRecordIndex = (
|
||||
return primaryMatches.length === 1 ? primaryMatches[0].index : -1;
|
||||
};
|
||||
|
||||
export const reorderProviderRecords = (
|
||||
records: Record<string, unknown>[],
|
||||
scopeRows: ProviderRecordIdentity[],
|
||||
source: ProviderRecordIdentity,
|
||||
target: ProviderRecordIdentity,
|
||||
) => {
|
||||
const sourceIndex = resolveProviderRecordIndex(records, source);
|
||||
const targetIndex = resolveProviderRecordIndex(records, target);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return null;
|
||||
|
||||
const scopedIndexes = scopeRows
|
||||
.map((row) => resolveProviderRecordIndex(records, row))
|
||||
.filter((index, position, indexes) => index >= 0 && indexes.indexOf(index) === position)
|
||||
.sort((left, right) => left - right);
|
||||
const sourcePosition = scopedIndexes.indexOf(sourceIndex);
|
||||
const targetPosition = scopedIndexes.indexOf(targetIndex);
|
||||
if (sourcePosition < 0 || targetPosition < 0) return null;
|
||||
|
||||
const reordered = scopedIndexes.map((index) => stripResponseFields(records[index]));
|
||||
const [moved] = reordered.splice(sourcePosition, 1);
|
||||
reordered.splice(targetPosition, 0, moved);
|
||||
|
||||
const next = records.map(stripResponseFields);
|
||||
scopedIndexes.forEach((recordIndex, position) => {
|
||||
next[recordIndex] = reordered[position];
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
export const providerRecordWithDisabledState = (
|
||||
section: ProviderSection,
|
||||
record: Record<string, unknown>,
|
||||
@@ -674,8 +780,13 @@ export function ApiAccessPage() {
|
||||
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
|
||||
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
|
||||
const [healthDialogRow, setHealthDialogRow] = useState<ProviderRow | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const activeDefinition = definitionFor(activeCategory);
|
||||
const activeSection = activeDefinition.section;
|
||||
const dragSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const loadProviders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -978,6 +1089,34 @@ export function ApiAccessPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const reorderProviders = async (source: ProviderRow, target: ProviderRow) => {
|
||||
if (source.section !== target.section || source.index === target.index) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const latestRows = sectionRecordsFromConfig(latestConfig, source.section);
|
||||
const nextRows = reorderProviderRecords(latestRows, rows, source, target);
|
||||
if (!nextRows) throw new Error(t('apiAccess.error.stale'));
|
||||
await managementApi.put(`/${source.section}`, nextRows);
|
||||
setNotice(t('apiAccess.notice.reordered'));
|
||||
await loadProviders();
|
||||
} catch (requestError) {
|
||||
setError(requestErrorMessage(requestError));
|
||||
} finally {
|
||||
setDragOverId(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setDragOverId(null);
|
||||
const source = rows.find((row) => providerDragId(row) === String(event.active.id));
|
||||
const target = rows.find((row) => providerDragId(row) === String(event.over?.id ?? ''));
|
||||
if (source && target) void reorderProviders(source, target);
|
||||
};
|
||||
|
||||
const totalCount = Object.values(records).reduce((sum, items) => sum + items.length, 0);
|
||||
|
||||
const countForDefinition = (definition: ProviderDefinition) =>
|
||||
@@ -1046,9 +1185,27 @@ export function ApiAccessPage() {
|
||||
<span>{filter ? t('apiAccess.empty.tryKeyword') : t('apiAccess.empty.addFirst')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="real-provider-list">
|
||||
{rows.map((row) => (
|
||||
<article className="real-provider-row" key={`${row.section}-${row.index}-${row.authIndex}`}>
|
||||
<DndContext
|
||||
sensors={dragSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={() => setNotice('')}
|
||||
onDragOver={({ over }) => setDragOverId(over ? String(over.id) : null)}
|
||||
onDragCancel={() => setDragOverId(null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={rows.map(providerDragId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="real-provider-list">
|
||||
{rows.map((row) => (
|
||||
<SortableProviderRow
|
||||
key={`${row.section}-${row.index}-${row.authIndex}`}
|
||||
row={row}
|
||||
disabled={busy || rows.length < 2}
|
||||
dragLabel={t('apiAccess.dragHandle', { remark: row.remark || row.name })}
|
||||
isDragOver={dragOverId === providerDragId(row)}
|
||||
>
|
||||
<div className="provider-row-main">
|
||||
<div className="provider-row-title">
|
||||
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
|
||||
@@ -1095,9 +1252,11 @@ export function ApiAccessPage() {
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</SortableProviderRow>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+32
-6
@@ -3684,12 +3684,37 @@ textarea:focus-visible {
|
||||
|
||||
.real-provider-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content;
|
||||
grid-template-columns: 32px minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
min-height: 88px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--theme-eeece6);
|
||||
transition: background 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
.real-provider-row.dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.real-provider-row.drag-over {
|
||||
background: var(--theme-f4f1ea);
|
||||
box-shadow: inset 0 2px 0 var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.provider-drag-handle {
|
||||
margin-top: -2px;
|
||||
cursor: grab;
|
||||
color: var(--theme-9b958c);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.provider-drag-handle:hover {
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.provider-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.provider-row-main,
|
||||
@@ -7535,7 +7560,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.real-provider-row {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
grid-template-columns: 32px minmax(0, 1fr) max-content;
|
||||
}
|
||||
|
||||
.provider-row-meta {
|
||||
@@ -7655,7 +7680,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.real-provider-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.real-auth-file-row {
|
||||
@@ -7979,10 +8004,11 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.real-provider-row {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.provider-row-actions {
|
||||
grid-column: 2;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -8070,7 +8096,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.real-provider-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.real-auth-file-row {
|
||||
@@ -8078,7 +8104,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.provider-row-actions {
|
||||
grid-column: 1;
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
providerCategoryMatchesRecord,
|
||||
providerRecordWithDisabledState,
|
||||
providerSectionOrder,
|
||||
reorderProviderRecords,
|
||||
resolveProviderRecordIndex,
|
||||
sectionRecordsFromConfig,
|
||||
stripResponseFields,
|
||||
@@ -295,6 +296,37 @@ describe('API 接入配置合并', () => {
|
||||
expect(index).toBe(1);
|
||||
});
|
||||
|
||||
it('拖动筛选后的供应商时保留隐藏供应商所在槽位', () => {
|
||||
const records = [
|
||||
{ name: 'OpenAI A', 'auth-index': 'runtime-a' },
|
||||
{ name: 'DeepSeek', 'base-url': 'https://api.deepseek.com' },
|
||||
{ name: 'OpenAI B', 'auth-index': 'runtime-b' },
|
||||
];
|
||||
const openAiA = {
|
||||
section: 'openai-compatibility' as const,
|
||||
index: 0,
|
||||
name: 'OpenAI A',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
};
|
||||
const openAiB = {
|
||||
section: 'openai-compatibility' as const,
|
||||
index: 2,
|
||||
name: 'OpenAI B',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
};
|
||||
|
||||
const reordered = reorderProviderRecords(records, [openAiA, openAiB], openAiB, openAiA);
|
||||
|
||||
expect(reordered?.map((record) => record.name)).toEqual([
|
||||
'OpenAI B',
|
||||
'DeepSeek',
|
||||
'OpenAI A',
|
||||
]);
|
||||
expect(reordered?.[0]['auth-index']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('校验自定义请求头格式', () => {
|
||||
expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({
|
||||
Authorization: 'Bearer abc:def',
|
||||
|
||||
Reference in new issue
Block a user