|
@@ -19,6 +19,8 @@ import {
|
|
|
Input,
|
|
Input,
|
|
|
Modal,
|
|
Modal,
|
|
|
Popconfirm,
|
|
Popconfirm,
|
|
|
|
|
+ Radio,
|
|
|
|
|
+ Select,
|
|
|
Skeleton,
|
|
Skeleton,
|
|
|
Space,
|
|
Space,
|
|
|
Table,
|
|
Table,
|
|
@@ -35,6 +37,7 @@ import { SectionCard } from '../components/common/SectionCard';
|
|
|
import { StatusPill, type StatusTone } from '../components/common/StatusPill';
|
|
import { StatusPill, type StatusTone } from '../components/common/StatusPill';
|
|
|
import {
|
|
import {
|
|
|
buildApiUsageExamples,
|
|
buildApiUsageExamples,
|
|
|
|
|
+ buildInboundMessageApiUsageExamples,
|
|
|
buildMailboxApiUsageExamples,
|
|
buildMailboxApiUsageExamples,
|
|
|
canCopyFullApiToken,
|
|
canCopyFullApiToken,
|
|
|
formatApiTokenPrefix,
|
|
formatApiTokenPrefix,
|
|
@@ -43,47 +46,79 @@ import {
|
|
|
import { useAppContext } from '../frontend/app-context';
|
|
import { useAppContext } from '../frontend/app-context';
|
|
|
import { useI18n } from '../frontend/i18n/react';
|
|
import { useI18n } from '../frontend/i18n/react';
|
|
|
import { api } from '../frontend/services/api';
|
|
import { api } from '../frontend/services/api';
|
|
|
-import type { ApiToken, RuntimeConfig } from '../frontend/types';
|
|
|
|
|
|
|
+import type {
|
|
|
|
|
+ ApiToken,
|
|
|
|
|
+ ApiTokenInput,
|
|
|
|
|
+ ApiTokenMailboxAccess,
|
|
|
|
|
+ InboundMailbox,
|
|
|
|
|
+ RuntimeConfig
|
|
|
|
|
+} from '../frontend/types';
|
|
|
|
|
|
|
|
interface TokenFormValues {
|
|
interface TokenFormValues {
|
|
|
name: string;
|
|
name: string;
|
|
|
scopes: string[];
|
|
scopes: string[];
|
|
|
expiresAt?: string;
|
|
expiresAt?: string;
|
|
|
|
|
+ mailboxAccess: ApiTokenMailboxAccess;
|
|
|
|
|
+ mailboxIds: number[];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+type SecretModalMode = 'created' | 'rotated';
|
|
|
|
|
+
|
|
|
const scopeOptions = [
|
|
const scopeOptions = [
|
|
|
{ label: 'send', value: 'send' },
|
|
{ label: 'send', value: 'send' },
|
|
|
{ label: 'mailboxes:read', value: 'mailboxes:read' },
|
|
{ label: 'mailboxes:read', value: 'mailboxes:read' },
|
|
|
- { label: 'mailboxes:write', value: 'mailboxes:write' }
|
|
|
|
|
|
|
+ { label: 'mailboxes:write', value: 'mailboxes:write' },
|
|
|
|
|
+ { label: 'messages:read', value: 'messages:read' }
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
export default function ApiTokens() {
|
|
export default function ApiTokens() {
|
|
|
const { message } = AntApp.useApp();
|
|
const { message } = AntApp.useApp();
|
|
|
const { t } = useI18n();
|
|
const { t } = useI18n();
|
|
|
- const { config } = useAppContext();
|
|
|
|
|
|
|
+ const { config, user } = useAppContext();
|
|
|
const [form] = Form.useForm<TokenFormValues>();
|
|
const [form] = Form.useForm<TokenFormValues>();
|
|
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
|
|
|
|
+ const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [loadError, setLoadError] = useState('');
|
|
const [loadError, setLoadError] = useState('');
|
|
|
|
|
+ const [mailboxError, setMailboxError] = useState('');
|
|
|
const [actionKey, setActionKey] = useState('');
|
|
const [actionKey, setActionKey] = useState('');
|
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
|
const [editingToken, setEditingToken] = useState<ApiToken | null>(null);
|
|
const [editingToken, setEditingToken] = useState<ApiToken | null>(null);
|
|
|
const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
|
|
const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
|
|
|
- const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
|
|
|
|
|
|
|
+ const [revealedToken, setRevealedToken] = useState<ApiToken | null>(null);
|
|
|
|
|
+ const [secretModalMode, setSecretModalMode] = useState<SecretModalMode>('created');
|
|
|
const [guideOpen, setGuideOpen] = useState(false);
|
|
const [guideOpen, setGuideOpen] = useState(false);
|
|
|
|
|
+ const selectedScopes = Form.useWatch('scopes', form) || [];
|
|
|
|
|
+ const selectedMailboxAccess = Form.useWatch('mailboxAccess', form) || 'owner';
|
|
|
|
|
+ const messagesReadEnabled = selectedScopes.includes('messages:read');
|
|
|
|
|
+ const isAdmin = user?.role === 'admin';
|
|
|
|
|
|
|
|
const loadData = useCallback(async () => {
|
|
const loadData = useCallback(async () => {
|
|
|
setLoading(true);
|
|
setLoading(true);
|
|
|
setLoadError('');
|
|
setLoadError('');
|
|
|
- try {
|
|
|
|
|
- const tokenResult = await api.apiTokens();
|
|
|
|
|
- setTokens(tokenResult.tokens || []);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- setLoadError(error instanceof Error ? error.message : t('common.error'));
|
|
|
|
|
- } finally {
|
|
|
|
|
- setLoading(false);
|
|
|
|
|
|
|
+ setMailboxError('');
|
|
|
|
|
+ const [tokenResult, mailboxResult] = await Promise.allSettled([
|
|
|
|
|
+ api.apiTokens(),
|
|
|
|
|
+ api.inboundMailboxes(isAdmin)
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ if (tokenResult.status === 'fulfilled') {
|
|
|
|
|
+ const nextTokens = tokenResult.value.tokens || [];
|
|
|
|
|
+ setTokens(nextTokens);
|
|
|
|
|
+ setSelectedToken((current) => current
|
|
|
|
|
+ ? nextTokens.find((token) => token.id === current.id) || null
|
|
|
|
|
+ : null);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setLoadError(tokenResult.reason instanceof Error ? tokenResult.reason.message : t('common.error'));
|
|
|
}
|
|
}
|
|
|
- }, [t]);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if (mailboxResult.status === 'fulfilled') {
|
|
|
|
|
+ setMailboxes(mailboxResult.value.mailboxes || []);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setMailboxError(mailboxResult.reason instanceof Error ? mailboxResult.reason.message : t('common.error'));
|
|
|
|
|
+ }
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }, [isAdmin, t]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
void loadData();
|
|
void loadData();
|
|
@@ -98,17 +133,20 @@ export default function ApiTokens() {
|
|
|
function openCreate() {
|
|
function openCreate() {
|
|
|
setEditingToken(null);
|
|
setEditingToken(null);
|
|
|
form.resetFields();
|
|
form.resetFields();
|
|
|
- form.setFieldsValue({ name: '', scopes: ['send'], expiresAt: '' });
|
|
|
|
|
|
|
+ form.setFieldsValue({ name: '', scopes: ['send'], expiresAt: '', mailboxAccess: 'owner', mailboxIds: [] });
|
|
|
setEditorOpen(true);
|
|
setEditorOpen(true);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function openEdit(token: ApiToken) {
|
|
function openEdit(token: ApiToken) {
|
|
|
|
|
+ const mailboxAccess = token.mailboxAccess === 'all' && !isAdmin ? 'owner' : token.mailboxAccess || 'owner';
|
|
|
setEditingToken(token);
|
|
setEditingToken(token);
|
|
|
form.resetFields();
|
|
form.resetFields();
|
|
|
form.setFieldsValue({
|
|
form.setFieldsValue({
|
|
|
name: token.name,
|
|
name: token.name,
|
|
|
scopes: token.scopes || ['send'],
|
|
scopes: token.scopes || ['send'],
|
|
|
- expiresAt: toDatetimeLocal(token.expiresAt)
|
|
|
|
|
|
|
+ expiresAt: toDatetimeLocal(token.expiresAt),
|
|
|
|
|
+ mailboxAccess,
|
|
|
|
|
+ mailboxIds: token.mailboxIds || []
|
|
|
});
|
|
});
|
|
|
setEditorOpen(true);
|
|
setEditorOpen(true);
|
|
|
}
|
|
}
|
|
@@ -123,9 +161,12 @@ export default function ApiTokens() {
|
|
|
await api.updateApiToken(editingToken.id, payload);
|
|
await api.updateApiToken(editingToken.id, payload);
|
|
|
message.success(t('tokens.updatedSuccess'));
|
|
message.success(t('tokens.updatedSuccess'));
|
|
|
} else {
|
|
} else {
|
|
|
- const result = await api.createApiToken(payload);
|
|
|
|
|
|
|
+ const result = await api.createApiToken({ ...payload, name: values.name.trim() });
|
|
|
message.success(t('tokens.createdSuccess'));
|
|
message.success(t('tokens.createdSuccess'));
|
|
|
- if (result.token && canCopyFullApiToken(result.token)) setCreatedToken(result.token);
|
|
|
|
|
|
|
+ if (result.token && canCopyFullApiToken(result.token)) {
|
|
|
|
|
+ setSecretModalMode('created');
|
|
|
|
|
+ setRevealedToken(result.token);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
setEditorOpen(false);
|
|
setEditorOpen(false);
|
|
|
setEditingToken(null);
|
|
setEditingToken(null);
|
|
@@ -138,6 +179,22 @@ export default function ApiTokens() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ async function rotateToken(token: ApiToken) {
|
|
|
|
|
+ setActionKey(`rotate:${token.id}`);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await api.rotateApiToken(token.id);
|
|
|
|
|
+ message.success(t('tokens.rotatedSuccess'));
|
|
|
|
|
+ setSecretModalMode('rotated');
|
|
|
|
|
+ setRevealedToken(result.token);
|
|
|
|
|
+ setSelectedToken(result.token);
|
|
|
|
|
+ await loadData();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ message.error(error instanceof Error ? error.message : t('common.error'));
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setActionKey('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async function revokeToken(token: ApiToken) {
|
|
async function revokeToken(token: ApiToken) {
|
|
|
setActionKey(`revoke:${token.id}`);
|
|
setActionKey(`revoke:${token.id}`);
|
|
|
try {
|
|
try {
|
|
@@ -156,32 +213,49 @@ export default function ApiTokens() {
|
|
|
{
|
|
{
|
|
|
title: t('tokens.name'),
|
|
title: t('tokens.name'),
|
|
|
dataIndex: 'name',
|
|
dataIndex: 'name',
|
|
|
|
|
+ width: 180,
|
|
|
render: (value: string) => <Typography.Text strong>{value}</Typography.Text>
|
|
render: (value: string) => <Typography.Text strong>{value}</Typography.Text>
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- title: t('tokens.prefix'),
|
|
|
|
|
- dataIndex: 'tokenPrefix',
|
|
|
|
|
- width: 180,
|
|
|
|
|
- render: (_value, token) => <Typography.Text code>{formatApiTokenPrefix(token)}</Typography.Text>
|
|
|
|
|
|
|
+ title: t('tokens.fullToken'),
|
|
|
|
|
+ dataIndex: 'token',
|
|
|
|
|
+ width: 380,
|
|
|
|
|
+ render: (_value, token) => <TokenValue token={token} onCopy={copyValue} t={t} />
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
title: t('tokens.scopes'),
|
|
title: t('tokens.scopes'),
|
|
|
dataIndex: 'scopes',
|
|
dataIndex: 'scopes',
|
|
|
|
|
+ width: 250,
|
|
|
render: (scopes: string[]) => <Space size={[4, 4]} wrap>{(scopes || []).map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space>
|
|
render: (scopes: string[]) => <Space size={[4, 4]} wrap>{(scopes || []).map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space>
|
|
|
},
|
|
},
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t('tokens.mailboxAccess'),
|
|
|
|
|
+ dataIndex: 'mailboxAccess',
|
|
|
|
|
+ width: 210,
|
|
|
|
|
+ render: (_value, token) => mailboxAccessSummary(token, t)
|
|
|
|
|
+ },
|
|
|
{
|
|
{
|
|
|
title: t('tokens.status'),
|
|
title: t('tokens.status'),
|
|
|
dataIndex: 'status',
|
|
dataIndex: 'status',
|
|
|
width: 120,
|
|
width: 120,
|
|
|
render: (_value, token) => <StatusPill tone={tokenStatusTone(token.status)}>{tokenStatusText(token.status, t)}</StatusPill>
|
|
render: (_value, token) => <StatusPill tone={tokenStatusTone(token.status)}>{tokenStatusText(token.status, t)}</StatusPill>
|
|
|
},
|
|
},
|
|
|
- { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', width: 190, render: (value?: string) => value ? formatOptionalTime(value) : t('tokens.neverUsed') },
|
|
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t('tokens.lastUsed'),
|
|
|
|
|
+ dataIndex: 'lastUsedAt',
|
|
|
|
|
+ width: 190,
|
|
|
|
|
+ responsive: ['xl'],
|
|
|
|
|
+ render: (value?: string) => value ? formatOptionalTime(value) : t('tokens.neverUsed')
|
|
|
|
|
+ },
|
|
|
{
|
|
{
|
|
|
title: t('tokens.actions'),
|
|
title: t('tokens.actions'),
|
|
|
width: 180,
|
|
width: 180,
|
|
|
|
|
+ fixed: 'right',
|
|
|
render: (_value, token) => (
|
|
render: (_value, token) => (
|
|
|
<Space onClick={(event) => event.stopPropagation()}>
|
|
<Space onClick={(event) => event.stopPropagation()}>
|
|
|
- <Button aria-label={t('tokens.copyPrefix')} icon={<CopyOutlined />} onClick={() => void copyValue(token.tokenPrefix)} />
|
|
|
|
|
|
|
+ {!canCopyFullApiToken(token) ? (
|
|
|
|
|
+ <RotateTokenConfirm token={token} loading={actionKey === `rotate:${token.id}`} onRotate={rotateToken} t={t} />
|
|
|
|
|
+ ) : null}
|
|
|
<Button aria-label={`${t('tokens.edit')} ${token.name}`} disabled={token.status === 'revoked'} icon={<EditOutlined />} onClick={() => openEdit(token)} />
|
|
<Button aria-label={`${t('tokens.edit')} ${token.name}`} disabled={token.status === 'revoked'} icon={<EditOutlined />} onClick={() => openEdit(token)} />
|
|
|
<Popconfirm title={t('tokens.revokeConfirm')} onConfirm={() => void revokeToken(token)} disabled={token.status === 'revoked'}>
|
|
<Popconfirm title={t('tokens.revokeConfirm')} onConfirm={() => void revokeToken(token)} disabled={token.status === 'revoked'}>
|
|
|
<Button aria-label={`${t('tokens.revoke')} ${token.name}`} danger disabled={token.status === 'revoked'} icon={<DeleteOutlined />} loading={actionKey === `revoke:${token.id}`} />
|
|
<Button aria-label={`${t('tokens.revoke')} ${token.name}`} danger disabled={token.status === 'revoked'} icon={<DeleteOutlined />} loading={actionKey === `revoke:${token.id}`} />
|
|
@@ -191,6 +265,16 @@ export default function ApiTokens() {
|
|
|
}
|
|
}
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
|
|
+ const mailboxOptions = mailboxes.map((mailbox) => ({
|
|
|
|
|
+ value: mailbox.id,
|
|
|
|
|
+ label: `${mailbox.address}${isAdmin && mailbox.userId !== user?.id ? ` · UID ${mailbox.userId}` : ''}`
|
|
|
|
|
+ }));
|
|
|
|
|
+ const mailboxAccessOptions = [
|
|
|
|
|
+ { label: t('tokens.mailboxAccessOwner'), value: 'owner' },
|
|
|
|
|
+ { label: t('tokens.mailboxAccessSelected'), value: 'selected' },
|
|
|
|
|
+ ...(isAdmin ? [{ label: t('tokens.mailboxAccessAll'), value: 'all' }] : [])
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
|
<PageHeader
|
|
<PageHeader
|
|
@@ -211,13 +295,17 @@ export default function ApiTokens() {
|
|
|
rowKey="id"
|
|
rowKey="id"
|
|
|
columns={columns}
|
|
columns={columns}
|
|
|
dataSource={tokens}
|
|
dataSource={tokens}
|
|
|
- scroll={{ x: 980 }}
|
|
|
|
|
|
|
+ scroll={{ x: 1510 }}
|
|
|
onRow={(token) => ({
|
|
onRow={(token) => ({
|
|
|
onClick: () => setSelectedToken(token),
|
|
onClick: () => setSelectedToken(token),
|
|
|
style: { cursor: 'pointer' },
|
|
style: { cursor: 'pointer' },
|
|
|
tabIndex: 0,
|
|
tabIndex: 0,
|
|
|
onKeyDown: (event) => {
|
|
onKeyDown: (event) => {
|
|
|
- if (event.key === 'Enter' || event.key === ' ') setSelectedToken(token);
|
|
|
|
|
|
|
+ if (event.target !== event.currentTarget) return;
|
|
|
|
|
+ if (event.key === 'Enter' || event.key === ' ') {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ setSelectedToken(token);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
})}
|
|
})}
|
|
|
/>
|
|
/>
|
|
@@ -226,32 +314,75 @@ export default function ApiTokens() {
|
|
|
|
|
|
|
|
<Drawer
|
|
<Drawer
|
|
|
title={editingToken ? t('tokens.editTitle') : t('tokens.createTitle')}
|
|
title={editingToken ? t('tokens.editTitle') : t('tokens.createTitle')}
|
|
|
- width={520}
|
|
|
|
|
|
|
+ width={560}
|
|
|
open={editorOpen}
|
|
open={editorOpen}
|
|
|
onClose={() => setEditorOpen(false)}
|
|
onClose={() => setEditorOpen(false)}
|
|
|
destroyOnHidden
|
|
destroyOnHidden
|
|
|
footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setEditorOpen(false)}>{t('common.cancel')}</Button><Button type="primary" icon={<KeyOutlined />} loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void saveToken()}>{editingToken ? t('common.save') : t('tokens.create')}</Button></Space>}
|
|
footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setEditorOpen(false)}>{t('common.cancel')}</Button><Button type="primary" icon={<KeyOutlined />} loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void saveToken()}>{editingToken ? t('common.save') : t('tokens.create')}</Button></Space>}
|
|
|
>
|
|
>
|
|
|
- {!editingToken ? <Alert type="warning" showIcon message={t('tokens.createdWarning')} style={{ marginBottom: 20 }} /> : null}
|
|
|
|
|
- <Form form={form} layout="vertical" initialValues={{ scopes: ['send'] }}>
|
|
|
|
|
|
|
+ {!editingToken ? <Alert type="info" showIcon message={t('tokens.createdWarning')} style={{ marginBottom: 20 }} /> : null}
|
|
|
|
|
+ <Form form={form} layout="vertical" initialValues={{ scopes: ['send'], mailboxAccess: 'owner', mailboxIds: [] }}>
|
|
|
<Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}><Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" /></Form.Item>
|
|
<Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}><Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" /></Form.Item>
|
|
|
<Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}><Input type="datetime-local" /></Form.Item>
|
|
<Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}><Input type="datetime-local" /></Form.Item>
|
|
|
<Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}><Checkbox.Group options={scopeOptions} /></Form.Item>
|
|
<Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}><Checkbox.Group options={scopeOptions} /></Form.Item>
|
|
|
|
|
+ {messagesReadEnabled ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Form.Item name="mailboxAccess" label={t('tokens.mailboxAccess')} extra={t('tokens.mailboxAccessExtra')} rules={[{ required: true }]}>
|
|
|
|
|
+ <Radio.Group className="full-width" style={{ display: 'grid', gap: 4 }}>
|
|
|
|
|
+ {mailboxAccessOptions.map((option) => (
|
|
|
|
|
+ <Radio key={option.value} value={option.value} style={{ alignItems: 'center', minHeight: 44 }}>
|
|
|
|
|
+ {option.label}
|
|
|
|
|
+ </Radio>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </Radio.Group>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ {selectedMailboxAccess === 'selected' ? (
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="mailboxIds"
|
|
|
|
|
+ label={t('tokens.mailboxIds')}
|
|
|
|
|
+ rules={[{ type: 'array', min: 1, required: true, message: t('tokens.mailboxIdsRequired') }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Select
|
|
|
|
|
+ mode="multiple"
|
|
|
|
|
+ allowClear
|
|
|
|
|
+ showSearch
|
|
|
|
|
+ optionFilterProp="label"
|
|
|
|
|
+ options={mailboxOptions}
|
|
|
|
|
+ placeholder={t('tokens.mailboxIdsPlaceholder')}
|
|
|
|
|
+ status={mailboxError ? 'error' : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {mailboxError ? <Alert type="error" showIcon message={mailboxError} action={<Button size="small" onClick={() => void loadData()}>{t('common.refresh')}</Button>} /> : null}
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : null}
|
|
|
</Form>
|
|
</Form>
|
|
|
</Drawer>
|
|
</Drawer>
|
|
|
|
|
|
|
|
- <Drawer title={selectedToken?.name} width={520} open={Boolean(selectedToken)} onClose={() => setSelectedToken(null)}>
|
|
|
|
|
|
|
+ <Drawer title={selectedToken?.name} width={600} open={Boolean(selectedToken)} onClose={() => setSelectedToken(null)}>
|
|
|
{selectedToken ? (
|
|
{selectedToken ? (
|
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
<Space direction="vertical" size={20} className="full-width">
|
|
|
<Descriptions bordered column={1} size="small">
|
|
<Descriptions bordered column={1} size="small">
|
|
|
|
|
+ <Descriptions.Item label={t('tokens.fullToken')}><TokenValue token={selectedToken} onCopy={copyValue} t={t} /></Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.prefix')}><Typography.Text code>{formatApiTokenPrefix(selectedToken)}</Typography.Text></Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.prefix')}><Typography.Text code>{formatApiTokenPrefix(selectedToken)}</Typography.Text></Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.status')}><StatusPill tone={tokenStatusTone(selectedToken.status)}>{tokenStatusText(selectedToken.status, t)}</StatusPill></Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.status')}><StatusPill tone={tokenStatusTone(selectedToken.status)}>{tokenStatusText(selectedToken.status, t)}</StatusPill></Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.scopes')}><Space wrap>{selectedToken.scopes.map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space></Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.scopes')}><Space wrap>{selectedToken.scopes.map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space></Descriptions.Item>
|
|
|
|
|
+ <Descriptions.Item label={t('tokens.mailboxAccess')}>{mailboxAccessSummary(selectedToken, t)}</Descriptions.Item>
|
|
|
|
|
+ {selectedToken.scopes.includes('messages:read') && selectedToken.mailboxAccess === 'selected' ? (
|
|
|
|
|
+ <Descriptions.Item label={t('tokens.mailboxIds')}>
|
|
|
|
|
+ <Space size={[4, 4]} wrap>{selectedToken.mailboxIds.map((id) => <Tag key={id}>{mailboxes.find((mailbox) => mailbox.id === id)?.address || `#${id}`}</Tag>)}</Space>
|
|
|
|
|
+ </Descriptions.Item>
|
|
|
|
|
+ ) : null}
|
|
|
<Descriptions.Item label={t('tokens.expiresAt')}>{formatOptionalTime(selectedToken.expiresAt)}</Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.expiresAt')}>{formatOptionalTime(selectedToken.expiresAt)}</Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.lastUsed')}>{selectedToken.lastUsedAt ? formatOptionalTime(selectedToken.lastUsedAt) : t('tokens.neverUsed')}</Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.lastUsed')}>{selectedToken.lastUsedAt ? formatOptionalTime(selectedToken.lastUsedAt) : t('tokens.neverUsed')}</Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.createdAt')}>{formatOptionalTime(selectedToken.createdAt)}</Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.createdAt')}>{formatOptionalTime(selectedToken.createdAt)}</Descriptions.Item>
|
|
|
</Descriptions>
|
|
</Descriptions>
|
|
|
- <Alert type="info" showIcon message={t('tokens.secretUnavailable')} />
|
|
|
|
|
|
|
+ <Alert
|
|
|
|
|
+ type={canCopyFullApiToken(selectedToken) ? 'success' : 'warning'}
|
|
|
|
|
+ showIcon
|
|
|
|
|
+ message={canCopyFullApiToken(selectedToken) ? t('tokens.secretAvailable') : t('tokens.secretUnavailable')}
|
|
|
|
|
+ action={!canCopyFullApiToken(selectedToken) && selectedToken.status !== 'revoked' ? <RotateTokenConfirm token={selectedToken} loading={actionKey === `rotate:${selectedToken.id}`} onRotate={rotateToken} t={t} text /> : undefined}
|
|
|
|
|
+ />
|
|
|
<Button icon={<ReadOutlined />} onClick={() => setGuideOpen(true)}>{t('tokens.docsTitle')}</Button>
|
|
<Button icon={<ReadOutlined />} onClick={() => setGuideOpen(true)}>{t('tokens.docsTitle')}</Button>
|
|
|
</Space>
|
|
</Space>
|
|
|
) : null}
|
|
) : null}
|
|
@@ -260,21 +391,21 @@ export default function ApiTokens() {
|
|
|
<ApiGuideDrawer open={guideOpen} config={config} onClose={() => setGuideOpen(false)} onCopy={copyValue} />
|
|
<ApiGuideDrawer open={guideOpen} config={config} onClose={() => setGuideOpen(false)} onCopy={copyValue} />
|
|
|
|
|
|
|
|
<Modal
|
|
<Modal
|
|
|
- title={t('tokens.createdTitle')}
|
|
|
|
|
- open={Boolean(createdToken)}
|
|
|
|
|
|
|
+ title={secretModalMode === 'rotated' ? t('tokens.rotatedTitle') : t('tokens.createdTitle')}
|
|
|
|
|
+ open={Boolean(revealedToken)}
|
|
|
closable={false}
|
|
closable={false}
|
|
|
maskClosable={false}
|
|
maskClosable={false}
|
|
|
keyboard={false}
|
|
keyboard={false}
|
|
|
destroyOnHidden
|
|
destroyOnHidden
|
|
|
footer={[
|
|
footer={[
|
|
|
- <Button key="copy" icon={<CopyOutlined />} onClick={() => void copyValue(getCreatedApiTokenSecret(createdToken || {}) || '')}>{t('tokens.copyCreated')}</Button>,
|
|
|
|
|
- <Button key="done" type="primary" onClick={() => setCreatedToken(null)}>{t('common.confirm')}</Button>
|
|
|
|
|
|
|
+ <Button key="copy" icon={<CopyOutlined />} onClick={() => void copyValue(getCreatedApiTokenSecret(revealedToken || {}) || '')}>{t('tokens.copyCreated')}</Button>,
|
|
|
|
|
+ <Button key="done" type="primary" onClick={() => setRevealedToken(null)}>{t('common.confirm')}</Button>
|
|
|
]}
|
|
]}
|
|
|
>
|
|
>
|
|
|
- {createdToken ? (
|
|
|
|
|
|
|
+ {revealedToken ? (
|
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
|
- <Alert type="error" showIcon message={t('tokens.createdWarning')} />
|
|
|
|
|
- <CodeBlock value={getCreatedApiTokenSecret(createdToken) || ''} onCopy={copyValue} />
|
|
|
|
|
|
|
+ <Alert type={secretModalMode === 'rotated' ? 'warning' : 'info'} showIcon message={secretModalMode === 'rotated' ? t('tokens.rotatedWarning') : t('tokens.createdWarning')} />
|
|
|
|
|
+ <CodeBlock value={getCreatedApiTokenSecret(revealedToken) || ''} onCopy={copyValue} />
|
|
|
</Space>
|
|
</Space>
|
|
|
) : null}
|
|
) : null}
|
|
|
</Modal>
|
|
</Modal>
|
|
@@ -282,28 +413,98 @@ export default function ApiTokens() {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function TokenValue({ token, onCopy, t }: { token: ApiToken; onCopy: (value: string) => void; t: (key: string) => string }) {
|
|
|
|
|
+ if (!canCopyFullApiToken(token)) return <StatusPill tone="warning">{t('tokens.unrecoverable')}</StatusPill>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Space size={8} align="start" style={{ maxWidth: '100%' }}>
|
|
|
|
|
+ <Typography.Text code style={{ overflowWrap: 'anywhere', wordBreak: 'break-all' }}>{token.token}</Typography.Text>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ aria-label={`${t('tokens.copyFull')} ${token.name}`}
|
|
|
|
|
+ icon={<CopyOutlined />}
|
|
|
|
|
+ onClick={(event) => {
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ void onCopy(token.token || '');
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{ minHeight: 44, minWidth: 44 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function RotateTokenConfirm({ token, loading, onRotate, t, text = false }: { token: ApiToken; loading: boolean; onRotate: (token: ApiToken) => void; t: (key: string) => string; text?: boolean }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Popconfirm
|
|
|
|
|
+ title={t('tokens.rotateConfirm')}
|
|
|
|
|
+ description={t('tokens.rotateConfirmDescription')}
|
|
|
|
|
+ okText={t('tokens.rotate')}
|
|
|
|
|
+ okButtonProps={{ danger: true }}
|
|
|
|
|
+ onConfirm={() => void onRotate(token)}
|
|
|
|
|
+ disabled={token.status === 'revoked'}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Button
|
|
|
|
|
+ aria-label={`${t('tokens.rotate')} ${token.name}`}
|
|
|
|
|
+ icon={<ReloadOutlined />}
|
|
|
|
|
+ danger
|
|
|
|
|
+ disabled={token.status === 'revoked'}
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ style={{ minHeight: 44, minWidth: text ? undefined : 44 }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {text ? t('tokens.rotate') : null}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Popconfirm>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; config: RuntimeConfig | null; onClose: () => void; onCopy: (value: string) => void }) {
|
|
function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; config: RuntimeConfig | null; onClose: () => void; onCopy: (value: string) => void }) {
|
|
|
const { t } = useI18n();
|
|
const { t } = useI18n();
|
|
|
- const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
|
|
|
|
|
- const mailboxEndpoint = `${config?.appBaseUrl || window.location.origin}/api/mailboxes`;
|
|
|
|
|
|
|
+ const baseUrl = config?.appBaseUrl || window.location.origin;
|
|
|
|
|
+ const endpoint = `${baseUrl}/api/send`;
|
|
|
|
|
+ const mailboxEndpoint = `${baseUrl}/api/mailboxes`;
|
|
|
|
|
+ const inboundMessagesEndpoint = `${baseUrl}/api/inbound-messages`;
|
|
|
|
|
+ const inboundMailboxesEndpoint = `${baseUrl}/api/inbound-mailboxes`;
|
|
|
const examples = useMemo(() => buildApiUsageExamples({ endpoint, token: '<USER_API_TOKEN>', from: 'noreply@example.com', to: 'user@example.com' }), [endpoint]);
|
|
const examples = useMemo(() => buildApiUsageExamples({ endpoint, token: '<USER_API_TOKEN>', from: 'noreply@example.com', to: 'user@example.com' }), [endpoint]);
|
|
|
const mailboxExamples = useMemo(() => buildMailboxApiUsageExamples({ endpoint: mailboxEndpoint, token: '<MAILBOX_API_TOKEN>' }), [mailboxEndpoint]);
|
|
const mailboxExamples = useMemo(() => buildMailboxApiUsageExamples({ endpoint: mailboxEndpoint, token: '<MAILBOX_API_TOKEN>' }), [mailboxEndpoint]);
|
|
|
|
|
+ const inboundExamples = useMemo(() => buildInboundMessageApiUsageExamples({ endpoint: inboundMessagesEndpoint, mailboxEndpoint: inboundMailboxesEndpoint, token: '<MESSAGES_API_TOKEN>' }), [inboundMailboxesEndpoint, inboundMessagesEndpoint]);
|
|
|
return (
|
|
return (
|
|
|
- <Drawer title={t('tokens.docsTitle')} width={720} open={open} onClose={onClose}>
|
|
|
|
|
|
|
+ <Drawer title={t('tokens.docsTitle')} width={760} open={open} onClose={onClose}>
|
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
|
<Descriptions column={1} bordered size="small">
|
|
<Descriptions column={1} bordered size="small">
|
|
|
<Descriptions.Item label={t('tokens.endpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(endpoint) }}>{endpoint}</Typography.Text></Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.endpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(endpoint) }}>{endpoint}</Typography.Text></Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.mailboxEndpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(mailboxEndpoint) }}>{mailboxEndpoint}</Typography.Text></Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.mailboxEndpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(mailboxEndpoint) }}>{mailboxEndpoint}</Typography.Text></Descriptions.Item>
|
|
|
|
|
+ <Descriptions.Item label={t('tokens.messagesApi')}><Typography.Text code copyable={{ onCopy: () => onCopy(inboundMessagesEndpoint) }}>{inboundMessagesEndpoint}</Typography.Text></Descriptions.Item>
|
|
|
<Descriptions.Item label={t('tokens.authHeader')}><Typography.Text code>{t('tokens.authHeaderValue')}</Typography.Text></Descriptions.Item>
|
|
<Descriptions.Item label={t('tokens.authHeader')}><Typography.Text code>{t('tokens.authHeaderValue')}</Typography.Text></Descriptions.Item>
|
|
|
</Descriptions>
|
|
</Descriptions>
|
|
|
<Collapse
|
|
<Collapse
|
|
|
defaultActiveKey={['send']}
|
|
defaultActiveKey={['send']}
|
|
|
items={[
|
|
items={[
|
|
|
{
|
|
{
|
|
|
- key: 'send', label: t('tokens.sendApi'), children: <Space direction="vertical" size={12} className="full-width"><Typography.Text strong>{t('tokens.curlExample')}</Typography.Text><CodeBlock value={examples.curl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.requestExample')}</Typography.Text><CodeBlock value={examples.requestBody} onCopy={onCopy} /><Typography.Text strong>{t('tokens.responseExample')}</Typography.Text><CodeBlock value={examples.successResponse} onCopy={onCopy} /></Space>
|
|
|
|
|
|
|
+ key: 'send',
|
|
|
|
|
+ label: t('tokens.sendApi'),
|
|
|
|
|
+ children: <Space direction="vertical" size={12} className="full-width"><Typography.Text strong>{t('tokens.curlExample')}</Typography.Text><CodeBlock value={examples.curl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.requestExample')}</Typography.Text><CodeBlock value={examples.requestBody} onCopy={onCopy} /><Typography.Text strong>{t('tokens.responseExample')}</Typography.Text><CodeBlock value={examples.successResponse} onCopy={onCopy} /></Space>
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'mailboxes',
|
|
|
|
|
+ label: t('tokens.mailboxApi'),
|
|
|
|
|
+ children: <Space direction="vertical" size={12} className="full-width"><Alert type="info" showIcon message={t('tokens.mailboxApiHint')} /><Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text><CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} /></Space>
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- key: 'mailboxes', label: t('tokens.mailboxApi'), children: <Space direction="vertical" size={12} className="full-width"><Alert type="info" showIcon message={t('tokens.mailboxApiHint')} /><Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text><CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} /></Space>
|
|
|
|
|
|
|
+ key: 'messages',
|
|
|
|
|
+ label: t('tokens.messagesApi'),
|
|
|
|
|
+ children: (
|
|
|
|
|
+ <Space direction="vertical" size={12} className="full-width">
|
|
|
|
|
+ <Alert type="info" showIcon message={t('tokens.messagesApiHint')} description={t('tokens.messagesAccess')} />
|
|
|
|
|
+ <Typography.Text>{t('tokens.messagesFilters')}</Typography.Text>
|
|
|
|
|
+ <Typography.Text strong>{t('tokens.messagesList')}</Typography.Text>
|
|
|
|
|
+ <CodeBlock value={inboundExamples.listCurl} onCopy={onCopy} />
|
|
|
|
|
+ <CodeBlock value={inboundExamples.listResponse} onCopy={onCopy} />
|
|
|
|
|
+ <Typography.Text strong>{t('tokens.messageDetail')}</Typography.Text>
|
|
|
|
|
+ <CodeBlock value={inboundExamples.detailCurl} onCopy={onCopy} />
|
|
|
|
|
+ <CodeBlock value={inboundExamples.detailResponse} onCopy={onCopy} />
|
|
|
|
|
+ <Typography.Text strong>{t('tokens.mailboxFolders')}</Typography.Text>
|
|
|
|
|
+ <CodeBlock value={inboundExamples.foldersCurl} onCopy={onCopy} />
|
|
|
|
|
+ <CodeBlock value={inboundExamples.foldersResponse} onCopy={onCopy} />
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
]}
|
|
]}
|
|
|
/>
|
|
/>
|
|
@@ -313,8 +514,23 @@ function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; conf
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function toTokenPayload(values: TokenFormValues) {
|
|
|
|
|
- return { name: values.name.trim(), scopes: values.scopes, expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null };
|
|
|
|
|
|
|
+function toTokenPayload(values: TokenFormValues): ApiTokenInput {
|
|
|
|
|
+ const messagesRead = values.scopes.includes('messages:read');
|
|
|
|
|
+ const mailboxAccess = messagesRead ? values.mailboxAccess || 'owner' : 'owner';
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: values.name.trim(),
|
|
|
|
|
+ scopes: values.scopes,
|
|
|
|
|
+ expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null,
|
|
|
|
|
+ mailboxAccess,
|
|
|
|
|
+ mailboxIds: messagesRead && mailboxAccess === 'selected' ? values.mailboxIds || [] : []
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function mailboxAccessSummary(token: ApiToken, t: (key: string) => string) {
|
|
|
|
|
+ if (!token.scopes.includes('messages:read')) return '—';
|
|
|
|
|
+ if (token.mailboxAccess === 'all') return <StatusPill tone="warning">{t('tokens.mailboxAccessAll')}</StatusPill>;
|
|
|
|
|
+ if (token.mailboxAccess === 'selected') return <StatusPill tone="info">{t('tokens.mailboxAccessSelected')} · {token.mailboxIds.length}</StatusPill>;
|
|
|
|
|
+ return <StatusPill tone="neutral">{t('tokens.mailboxAccessOwner')}</StatusPill>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function toDatetimeLocal(value?: string | null) {
|
|
function toDatetimeLocal(value?: string | null) {
|