mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
feat(framework): 回灌 OpenFlare 分层、安全与运行时改进
将平台域持久化收敛为 repository 唯一入口,model 去掉 IO。 邮件头写入前清除 CR/LF,防止 header 注入。 httppool 支持可配置 Transport;batchwriter 增加 MinBatchSize/Stats,flush 失败交回批次;任务 PermanentError 作为 SkipRetry 终态。 设置与推送页的确认改为 AlertDialog;axios 去尾斜杠并按 Gin 数组序列化查询参数。 升级共享 Go 依赖(Gin、Asynq、OTel、GORM、Redis 等)。
This commit is contained in:
@@ -43,6 +43,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -74,6 +84,9 @@ import { PushService } from '@/lib/services/push';
|
||||
export function EventsTab() {
|
||||
const t = useTranslations('admin.push.events');
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
@@ -149,6 +162,7 @@ export function EventsTab() {
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
toast.success(t('configDeleteSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
|
||||
},
|
||||
@@ -455,11 +469,7 @@ export function EventsTab() {
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10'
|
||||
disabled={deleteEventMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('deleteEventConfirm'))) {
|
||||
deleteEventMutation.mutate(event.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setDeleteTarget(event)}
|
||||
>
|
||||
<Trash2 className='size-3' />
|
||||
</Button>
|
||||
@@ -919,6 +929,35 @@ export function EventsTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteEventTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteEventConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteEventMutation.isPending}>
|
||||
{t('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteEventMutation.isPending}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteEventMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -54,6 +64,9 @@ import { PushService } from '@/lib/services/push';
|
||||
export function SettingsTab() {
|
||||
const t = useTranslations('admin.push.settings');
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
@@ -96,6 +109,7 @@ export function SettingsTab() {
|
||||
const deleteChannelMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
toast.success(t('channelDeleteSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
|
||||
},
|
||||
@@ -419,15 +433,7 @@ export function SettingsTab() {
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t('deleteChannelConfirm', { name: ch.name }),
|
||||
)
|
||||
) {
|
||||
deleteChannelMutation.mutate(ch.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setDeleteTarget(ch)}
|
||||
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
|
||||
>
|
||||
<Trash2 className='size-2.5 mr-1' />
|
||||
@@ -772,6 +778,35 @@ export function SettingsTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteChannelTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteChannelConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteChannelMutation.isPending}>
|
||||
{t('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteChannelMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteChannelMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -53,6 +63,8 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { formatDateTime } from '@/i18n/format';
|
||||
import type { AppLocale } from '@/i18n/config';
|
||||
|
||||
type ConfirmTarget = { id: number; name: string };
|
||||
|
||||
export function AccessTokenMain() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -67,6 +79,12 @@ export function AccessTokenMain() {
|
||||
const [copiedId, setCopiedId] = React.useState<number | null>(null);
|
||||
const [newCreatedToken, setNewCreatedToken] =
|
||||
React.useState<CreateTokenResponse | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<ConfirmTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [rotateTarget, setRotateTarget] = React.useState<ConfirmTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 获取 Token 列表
|
||||
const accessTokensQuery = useQuery({
|
||||
@@ -98,6 +116,7 @@ export function AccessTokenMain() {
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.deleteAccessToken(id),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['user', 'access-tokens'],
|
||||
});
|
||||
@@ -112,6 +131,7 @@ export function AccessTokenMain() {
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.rotateAccessToken(id),
|
||||
onSuccess: (data) => {
|
||||
setRotateTarget(null);
|
||||
setNewCreatedToken(data);
|
||||
setViewDialogOpen(true);
|
||||
void queryClient.invalidateQueries({
|
||||
@@ -136,18 +156,6 @@ export function AccessTokenMain() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteToken = (id: number, name: string) => {
|
||||
if (window.confirm(ta('deleteConfirm', { name }))) {
|
||||
deleteTokenMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateToken = (id: number, name: string) => {
|
||||
if (window.confirm(ta('rotateConfirm', { name }))) {
|
||||
rotateTokenMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = async (text: string, id: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -297,7 +305,9 @@ export function AccessTokenMain() {
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5'
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
onClick={() =>
|
||||
setRotateTarget({ id: token.id, name: token.name })
|
||||
}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -310,7 +320,9 @@ export function AccessTokenMain() {
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5'
|
||||
onClick={() => handleDeleteToken(token.id, token.name)}
|
||||
onClick={() =>
|
||||
setDeleteTarget({ id: token.id, name: token.name })
|
||||
}
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
@@ -482,6 +494,64 @@ export function AccessTokenMain() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{ta('deleteConfirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{ta('deleteConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteTokenMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteTokenMutation.isPending
|
||||
? ta('revoking')
|
||||
: ta('confirmRevoke')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(rotateTarget)}
|
||||
onOpenChange={(open) => !open && setRotateTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{ta('rotateConfirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{ta('rotateConfirm', { name: rotateTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={rotateTokenMutation.isPending}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
onClick={() =>
|
||||
rotateTarget && rotateTokenMutation.mutate(rotateTarget.id)
|
||||
}
|
||||
>
|
||||
{rotateTokenMutation.isPending
|
||||
? ta('rotating')
|
||||
: ta('confirmRotate')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { AuthSourceModal } from '@/components/common/settings/auth-source-modal';
|
||||
import services from '@/lib/services';
|
||||
import type { AuthSource, SystemConfig } from '@/lib/services/admin';
|
||||
@@ -93,8 +103,10 @@ interface SecurityTabProps {
|
||||
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations('settings.security');
|
||||
const tCommon = useTranslations('common');
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AuthSource | null>(null);
|
||||
|
||||
const [capCount, setCapCount] = useState('');
|
||||
const [capDifficulty, setCapDifficulty] = useState('');
|
||||
@@ -233,6 +245,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
await services.adminAuthSource.deleteAuthSource(sourceId);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'public-sources'],
|
||||
@@ -490,17 +503,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
size='icon'
|
||||
className='size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors'
|
||||
disabled={deleteSourceMutation.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t('deleteAuthSourceConfirm', {
|
||||
name: source.display_name || source.name,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
deleteSourceMutation.mutate(source.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setDeleteTarget(source)}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
@@ -706,6 +709,37 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
await authSourcesQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteAuthSourceTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteAuthSourceConfirm', {
|
||||
name: deleteTarget?.display_name || deleteTarget?.name || '',
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSourceMutation.isPending}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteSourceMutation.isPending}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteSourceMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteSourceMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import services from '@/lib/services';
|
||||
import type { Template } from '@/lib/services/admin/types';
|
||||
import { toast } from 'sonner';
|
||||
@@ -35,6 +45,7 @@ export function TemplatesManager() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Template | null>(null);
|
||||
|
||||
// Form states
|
||||
const [key, setKey] = useState('');
|
||||
@@ -95,6 +106,7 @@ export function TemplatesManager() {
|
||||
await services.adminTemplate.deleteTemplate(key);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success(t('templateDeleted'));
|
||||
},
|
||||
@@ -225,15 +237,7 @@ export function TemplatesManager() {
|
||||
disabled={
|
||||
tmpl.is_system || deleteTemplateMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t('deleteTemplateConfirm', { name: tmpl.name }),
|
||||
)
|
||||
) {
|
||||
deleteTemplateMutation.mutate(tmpl.key);
|
||||
}
|
||||
}}
|
||||
onClick={() => setDeleteTarget(tmpl)}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
@@ -401,6 +405,35 @@ export function TemplatesManager() {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteTemplateTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteTemplateConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteTemplateMutation.isPending}>
|
||||
{t('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteTemplateMutation.isPending}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteTemplateMutation.mutate(deleteTarget.key)
|
||||
}
|
||||
>
|
||||
{deleteTemplateMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import axios, {
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { apiConfig } from './config';
|
||||
import { apiConfig, getApiBaseUrl } from './config';
|
||||
import {
|
||||
ApiErrorBase,
|
||||
ForbiddenError,
|
||||
@@ -26,11 +26,28 @@ const apiClient = axios.create({
|
||||
baseURL: apiConfig.baseURL,
|
||||
timeout: apiConfig.timeout,
|
||||
withCredentials: apiConfig.withCredentials,
|
||||
// Fail fast on slash redirect loops between Next dev proxy and Gin legacy routes.
|
||||
maxRedirects: 5,
|
||||
// Gin QueryArray("hosts") expects hosts=a&hosts=b, not hosts[]=a.
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/** Normalize legacy /api/* paths: strip trailing slash to avoid 308/301 loops in dev proxy. */
|
||||
function normalizeApiPath(url?: string): string | undefined {
|
||||
if (!url || !url.startsWith('/api/')) {
|
||||
return url;
|
||||
}
|
||||
if (url.length > 5 && url.endsWith('/')) {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求取消令牌存储
|
||||
*/
|
||||
@@ -73,6 +90,12 @@ function getRequestKey(config: {
|
||||
*/
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Browser must use same-origin relative URLs so Session Cookie hits Next rewrite.
|
||||
if (typeof window !== 'undefined') {
|
||||
config.baseURL = getApiBaseUrl();
|
||||
}
|
||||
config.url = normalizeApiPath(config.url);
|
||||
|
||||
const requestKey = getRequestKey(config);
|
||||
const source = axios.CancelToken.source();
|
||||
config.cancelToken = source.token;
|
||||
|
||||
@@ -245,6 +245,12 @@
|
||||
"rotateFailed": "Failed to rotate access token",
|
||||
"deleteConfirm": "Revoke token \u201c{name}\u201d? It will stop working immediately and cannot be recovered.",
|
||||
"rotateConfirm": "Rotate the secret for token \u201c{name}\u201d? A new secret will be issued and the old one will stop working immediately.",
|
||||
"deleteConfirmTitle": "Confirm token revocation",
|
||||
"rotateConfirmTitle": "Confirm token rotation",
|
||||
"confirmRevoke": "Confirm revoke",
|
||||
"confirmRotate": "Confirm rotate",
|
||||
"revoking": "Revoking...",
|
||||
"rotating": "Rotating...",
|
||||
"subtitle": "Manage API access secrets for development or third-party tools",
|
||||
"adminBadge": "Admin",
|
||||
"adminPermission": "Admin permission",
|
||||
@@ -374,7 +380,10 @@
|
||||
"type": "Type",
|
||||
"secretConfigured": "Secret configured",
|
||||
"secretNotConfigured": "Secret not configured",
|
||||
"deleteAuthSourceConfirm": "Delete authentication source \u201c{name}\u201d?",
|
||||
"deleteAuthSourceConfirm": "Delete authentication source \u201c{name}\u201d? This cannot be undone.",
|
||||
"deleteAuthSourceTitle": "Confirm delete authentication source",
|
||||
"confirmDelete": "Confirm delete",
|
||||
"deleting": "Deleting...",
|
||||
"noAuthSources": "No authentication sources configured. Click the button above to add one.",
|
||||
"captchaConfig": "CAPTCHA configuration (Cap CAPTCHA)",
|
||||
"captchaConfigDesc": "Configure Proof-of-Work (PoW) based invisible CAPTCHA to protect login from brute-force and credential-stuffing attacks",
|
||||
@@ -463,7 +472,10 @@
|
||||
"custom": "Custom",
|
||||
"identifier": "Identifier",
|
||||
"subject": "Subject",
|
||||
"deleteTemplateConfirm": "Delete template \u201c{name}\u201d?",
|
||||
"deleteTemplateConfirm": "Delete template \u201c{name}\u201d? This cannot be undone.",
|
||||
"deleteTemplateTitle": "Confirm delete template",
|
||||
"confirmDelete": "Confirm delete",
|
||||
"deleting": "Deleting...",
|
||||
"noTemplates": "No notification templates configured. Click the button above to add one.",
|
||||
"editTemplate": "Edit notification template",
|
||||
"addTemplateTitle": "Add notification template",
|
||||
@@ -876,7 +888,10 @@
|
||||
"noChannelSpecified": "No channel specified",
|
||||
"emailChannel": "Email",
|
||||
"configure": "Configure",
|
||||
"deleteEventConfirm": "Delete this notification event configuration?",
|
||||
"deleteEventConfirm": "Delete this notification event configuration? This cannot be undone.",
|
||||
"deleteEventTitle": "Confirm delete notification event",
|
||||
"confirmDelete": "Confirm delete",
|
||||
"deleting": "Deleting...",
|
||||
"delete": "Delete",
|
||||
"addEventDialogTitle": "Add notification event",
|
||||
"addEventDialogDesc": "Select a system event and bind push channels. It will be active immediately after creation.",
|
||||
@@ -944,7 +959,10 @@
|
||||
"noRemark": "No remark",
|
||||
"test": "Test",
|
||||
"edit": "Edit",
|
||||
"deleteChannelConfirm": "Delete channel \"{name}\"?",
|
||||
"deleteChannelConfirm": "Delete channel \"{name}\"? This cannot be undone.",
|
||||
"deleteChannelTitle": "Confirm delete channel",
|
||||
"confirmDelete": "Confirm delete",
|
||||
"deleting": "Deleting...",
|
||||
"delete": "Delete",
|
||||
"editChannelDialogTitle": "Edit channel",
|
||||
"createChannelDialogTitle": "Create channel",
|
||||
|
||||
@@ -245,6 +245,12 @@
|
||||
"rotateFailed": "轮换访问令牌失败",
|
||||
"deleteConfirm": "确定要删除并撤销令牌「{name}」吗?删除后此令牌将立即失效且不可恢复。",
|
||||
"rotateConfirm": "确定要轮换令牌「{name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。",
|
||||
"deleteConfirmTitle": "确认撤销访问令牌",
|
||||
"rotateConfirmTitle": "确认轮换访问令牌",
|
||||
"confirmRevoke": "确认撤销",
|
||||
"confirmRotate": "确认轮换",
|
||||
"revoking": "撤销中...",
|
||||
"rotating": "轮换中...",
|
||||
"subtitle": "管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API",
|
||||
"adminBadge": "管理员",
|
||||
"adminPermission": "管理员权限",
|
||||
@@ -374,7 +380,10 @@
|
||||
"type": "类型",
|
||||
"secretConfigured": "Secret 已配置",
|
||||
"secretNotConfigured": "Secret 未配置",
|
||||
"deleteAuthSourceConfirm": "确定删除认证源「{name}」吗?",
|
||||
"deleteAuthSourceConfirm": "确定删除认证源「{name}」吗?删除后无法恢复。",
|
||||
"deleteAuthSourceTitle": "确认删除认证源",
|
||||
"confirmDelete": "确认删除",
|
||||
"deleting": "删除中...",
|
||||
"noAuthSources": "暂无配置的认证源,点击上方按钮新增",
|
||||
"captchaConfig": "人机验证配置 (Cap CAPTCHA)",
|
||||
"captchaConfigDesc": "配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击",
|
||||
@@ -463,7 +472,10 @@
|
||||
"custom": "自定义",
|
||||
"identifier": "标识符",
|
||||
"subject": "主题",
|
||||
"deleteTemplateConfirm": "确定删除模板「{name}」吗?",
|
||||
"deleteTemplateConfirm": "确定删除模板「{name}」吗?删除后无法恢复。",
|
||||
"deleteTemplateTitle": "确认删除模板",
|
||||
"confirmDelete": "确认删除",
|
||||
"deleting": "删除中...",
|
||||
"noTemplates": "暂无配置的通知模板,点击上方按钮新增",
|
||||
"editTemplate": "编辑通知模板",
|
||||
"addTemplateTitle": "新增通知模板",
|
||||
@@ -876,7 +888,10 @@
|
||||
"noChannelSpecified": "未指定渠道",
|
||||
"emailChannel": "邮件",
|
||||
"configure": "配置",
|
||||
"deleteEventConfirm": "确定要删除该通知事件配置吗?",
|
||||
"deleteEventConfirm": "确定要删除该通知事件配置吗?删除后无法恢复。",
|
||||
"deleteEventTitle": "确认删除通知事件",
|
||||
"confirmDelete": "确认删除",
|
||||
"deleting": "删除中...",
|
||||
"delete": "删除",
|
||||
"addEventDialogTitle": "新增通知事件",
|
||||
"addEventDialogDesc": "选择系统内置的事件并绑定推送渠道,创建后即可开始生效",
|
||||
@@ -944,7 +959,10 @@
|
||||
"noRemark": "无备注",
|
||||
"test": "测试",
|
||||
"edit": "编辑",
|
||||
"deleteChannelConfirm": "确定要删除通道 \"{name}\" 吗?",
|
||||
"deleteChannelConfirm": "确定要删除通道「{name}」吗?删除后无法恢复。",
|
||||
"deleteChannelTitle": "确认删除通道",
|
||||
"confirmDelete": "确认删除",
|
||||
"deleting": "删除中...",
|
||||
"delete": "删除",
|
||||
"editChannelDialogTitle": "编辑消息通道",
|
||||
"createChannelDialogTitle": "新建消息通道",
|
||||
|
||||
@@ -7,6 +7,8 @@ const isExport = process.env.NEXT_STANDALONE_EXPORT === 'true';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
// Prevent 308 redirects on /api/* trailing slashes; dev rewrites proxy legacy APIs as-is.
|
||||
skipTrailingSlashRedirect: true,
|
||||
experimental: {},
|
||||
...(isExport
|
||||
? { output: 'export' }
|
||||
|
||||
Reference in New Issue
Block a user