feat: one-click upstream auth refresh from custom page viewer

- Add linked_upstream_id to CustomPage model with DB migration
- New POST /api/custom-pages/{pid}/refresh-auth endpoint extracts
  credentials from active remote browser and updates linked upstream
- PageViewer toolbar shows key icon button when page has linked upstream
- CustomPages form adds upstream dropdown for remote_browser pages
- Auth capture extracts New-Api-User from localStorage uid/user/self API
- Upstream client sends New-Api-User header in cookie auth mode
- Fix auth capture dialog: transparent background, field persistence,
  login URL defaults to base_url/login, focus on click for keyboard input
- Fix upstream test ASCII encoding with non-header characters validation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
SmartUp Developer
2026-05-19 09:27:14 +08:00
parent 7cb0ff1608
commit 4c71148ff9
13 changed files with 462 additions and 53 deletions
+20 -12
View File
@@ -259,6 +259,7 @@ export interface CustomPageData {
login_submit_selector: string | null
login_autofill_enabled: boolean
login_password_configured: boolean
linked_upstream_id: number | null
created_at: string
updated_at: string
}
@@ -279,6 +280,7 @@ export interface CustomPageForm {
login_submit_selector?: string
login_autofill_enabled?: boolean
login_password_clear?: boolean
linked_upstream_id?: number | null
}
export const customPagesApi = {
@@ -287,6 +289,7 @@ export const customPagesApi = {
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
refreshAuth: (id: number) => api.post<{ success: boolean; message: string }>(`/api/custom-pages/${id}/refresh-auth`),
}
// ——— Remote browser sessions ———
@@ -333,28 +336,33 @@ export interface AuthCaptureSession {
ws_url: string
}
export interface AuthCaptureCandidate {
type: 'bearer_token' | 'cookie' | 'credential' | 'api_key'
source: string
preview: string
label: string
confidence: number
value?: string
cookie_name?: string
cookie_value?: string
new_api_user?: string
}
export interface AuthCaptureResult {
cookies: Record<string, any>[]
storage: Record<string, string>
session_storage: Record<string, string>
auth_headers: Record<string, string>[]
candidates: {
type: 'bearer_token' | 'cookie' | 'credential' | 'api_key'
source: string
value: string
preview: string
label: string
confidence: number
cookie_name?: string
cookie_value?: string
}[]
candidates: AuthCaptureCandidate[]
}
export const authCaptureApi = {
createSession: (url: string, width?: number, height?: number) =>
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
extract: (sessionId: string) =>
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`),
extract: (sessionId: string, options?: { includeRaw?: boolean }) =>
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`, {
params: options?.includeRaw ? { include_raw: true } : undefined,
}),
closeSession: (sessionId: string) =>
api.delete(`/api/auth-capture/sessions/${sessionId}`),
wsUrl: (sessionId: string, token?: string) => {
+154 -23
View File
@@ -121,13 +121,13 @@
</span>
</div>
<div class="candidate-preview">
<code>{{ c.preview || maskValue(c.value) }}</code>
<code>{{ candidatePreview(c) }}</code>
</div>
</div>
</div>
<div class="candidate-actions">
<el-button size="small" @click="handleClose">关闭</el-button>
<el-button size="small" type="primary" :disabled="selectedIndex < 0" @click="confirmSelection">
<el-button size="small" type="primary" :disabled="selectedIndex < 0" :loading="applyingSelection" @click="confirmSelection">
填入当前表单
</el-button>
</div>
@@ -140,8 +140,9 @@
<script setup lang="ts">
import { ref, watch, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
import { authCaptureApi, browserSessionsApi, type AuthCaptureResult } from '@/api'
import { authCaptureApi, type AuthCaptureCandidate, type AuthCaptureResult } from '@/api'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
@@ -151,9 +152,64 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string }): void
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string; new_api_user?: string }): void
}>()
function candidatePreview(candidate: AuthCaptureCandidate): string {
return candidate.preview || maskValue(candidate.value || '')
}
function sameCandidate(a: AuthCaptureCandidate, b: AuthCaptureCandidate): boolean {
return a.type === b.type
&& a.source === b.source
&& a.label === b.label
&& a.preview === b.preview
&& a.confidence === b.confidence
&& a.cookie_name === b.cookie_name
}
function resolveCandidateValue(candidate: AuthCaptureCandidate): string {
return candidate.type === 'cookie'
? (candidate.cookie_value || candidate.value || '')
: (candidate.value || '')
}
function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureCandidate): string | undefined {
if (candidate.new_api_user) return candidate.new_api_user
const stores = [rawResult.storage, rawResult.session_storage]
for (const store of stores) {
const uid = store?.uid
if (uid) return String(uid)
const userRaw = store?.user
if (userRaw) {
try {
const user = JSON.parse(userRaw)
if (user?.id) return String(user.id)
if (user?.user_id) return String(user.user_id)
if (user?.userId) return String(user.userId)
} catch {}
}
const statusRaw = store?.status
if (statusRaw) {
try {
const status = JSON.parse(statusRaw)
const id = status?.user?.id || status?.id || status?.data?.id
if (id) return String(id)
} catch {}
}
}
return undefined
}
function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
if (candidates.length === 0) return -1
const sessionCookie = candidates.findIndex((candidate) => candidate.type === 'cookie' && candidate.cookie_name === 'session')
if (sessionCookie >= 0) return sessionCookie
const anyCookie = candidates.findIndex((candidate) => candidate.type === 'cookie')
if (anyCookie >= 0) return anyCookie
return candidates.length === 1 ? 0 : -1
}
const auth = useAuthStore()
const visible = ref(props.modelValue)
watch(() => props.modelValue, (v) => { visible.value = v })
@@ -161,15 +217,41 @@ watch(() => props.modelValue, (v) => { visible.value = v })
const targetUrl = ref(props.initialUrl || '')
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
const AUTH_CAPTURE_STORAGE_KEY = 'smartup_auth_capture_fields'
function loadSavedFields() {
try {
const raw = localStorage.getItem(AUTH_CAPTURE_STORAGE_KEY)
if (!raw) return
const saved = JSON.parse(raw)
if (saved.url) targetUrl.value = saved.url
if (saved.username) { loginUsername.value = saved.username; showExtraFields.value = true }
if (saved.password) { loginPassword.value = saved.password; showExtraFields.value = true }
} catch {}
}
function saveFields() {
try {
localStorage.setItem(AUTH_CAPTURE_STORAGE_KEY, JSON.stringify({
url: targetUrl.value,
username: loginUsername.value,
password: loginPassword.value,
}))
} catch {}
}
// Auto-fill
const showExtraFields = ref(false)
const loginUsername = ref('')
const loginPassword = ref('')
loadSavedFields()
// Session + WS
const sessionId = ref('')
const launching = ref(false)
const extracting = ref(false)
const applyingSelection = ref(false)
const extracted = ref(false)
const result = ref<AuthCaptureResult | null>(null)
const selectedIndex = ref(-1)
@@ -180,12 +262,26 @@ const frameRef = ref<HTMLElement | null>(null)
let ws: WebSocket | null = null
let pointerDown = false
let frameW = 1; let frameH = 1 // natural dimensions of the frame
let prevFrameUrl = '' // previous blob URL to revoke
let prevFrameUrl = '' // previous blob URL pending cleanup
function revokeFrameUrl(url: string) {
if (url) URL.revokeObjectURL(url)
}
function clearFrameUrls() {
revokeFrameUrl(frameUrl.value)
if (prevFrameUrl && prevFrameUrl !== frameUrl.value) {
revokeFrameUrl(prevFrameUrl)
}
frameUrl.value = ''
prevFrameUrl = ''
}
// ——— Launch ———
async function launchBrowser() {
if (!targetUrl.value) return
saveFields()
launching.value = true
try {
const res = await authCaptureApi.createSession(targetUrl.value)
@@ -213,11 +309,20 @@ function connectWs() {
ws.onmessage = (evt) => {
if (evt.data instanceof ArrayBuffer) {
// Binary JPEG frame — revoke previous to avoid memory leak
if (prevFrameUrl) URL.revokeObjectURL(prevFrameUrl)
// Binary JPEG frame — swap in the new URL before cleaning up the old one
const blob = new Blob([evt.data], { type: 'image/jpeg' })
prevFrameUrl = URL.createObjectURL(blob)
frameUrl.value = prevFrameUrl
const nextFrameUrl = URL.createObjectURL(blob)
const previousFrameUrl = frameUrl.value
frameUrl.value = nextFrameUrl
prevFrameUrl = previousFrameUrl
if (previousFrameUrl) {
void nextTick(() => {
revokeFrameUrl(previousFrameUrl)
if (prevFrameUrl === previousFrameUrl) {
prevFrameUrl = ''
}
})
}
} else {
// JSON message (init, error, etc.)
try {
@@ -232,6 +337,7 @@ function connectWs() {
ws.onclose = () => {
wsConnected.value = false
ws = null
clearFrameUrls()
}
ws.onerror = () => {
@@ -264,9 +370,10 @@ function scalePoint(e: PointerEvent): { x: number; y: number } {
}
function onPointerDown(e: PointerEvent) {
frameRef.value?.focus({ preventScroll: true })
pointerDown = true
const p = scalePoint(e)
wsSend({ type: e.buttons === 2 ? 'mousedown' : 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
wsSend({ type: 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
}
function onPointerMove(e: PointerEvent) {
@@ -313,7 +420,7 @@ async function extractCredentials() {
const res = await authCaptureApi.extract(sessionId.value)
result.value = res.data
extracted.value = true
selectedIndex.value = res.data.candidates.length === 1 ? 0 : -1
selectedIndex.value = defaultCandidateIndex(res.data.candidates)
} catch (e: any) {
console.error('extract failed', e)
} finally {
@@ -321,17 +428,40 @@ async function extractCredentials() {
}
}
function confirmSelection() {
if (selectedIndex.value < 0 || !result.value) return
const c = result.value.candidates[selectedIndex.value]
emit('select', {
type: c.type,
value: c.type === 'cookie' ? (c.cookie_value || c.value) : c.value,
source: c.source,
cookie_name: c.cookie_name,
cookie_value: c.cookie_value,
})
closeDialog()
async function confirmSelection() {
if (selectedIndex.value < 0 || !result.value || !sessionId.value) return
const selectedCandidate = result.value.candidates[selectedIndex.value]
applyingSelection.value = true
try {
const rawResult = await authCaptureApi.extract(sessionId.value, { includeRaw: true })
const fullCandidate = rawResult.data.candidates.find((candidate) => sameCandidate(candidate, selectedCandidate))
if (!fullCandidate) {
ElMessage.error('未找到完整认证信息,请重新提取后再试')
return
}
const resolvedValue = resolveCandidateValue(fullCandidate)
if (!resolvedValue) {
ElMessage.error('认证信息为空,请重新提取后再试')
return
}
emit('select', {
type: fullCandidate.type,
value: resolvedValue,
source: fullCandidate.source,
cookie_name: fullCandidate.cookie_name,
cookie_value: fullCandidate.cookie_value,
new_api_user: resolveNewApiUser(rawResult.data, fullCandidate),
})
closeDialog()
} catch (e: any) {
console.error('apply extract failed', e)
ElMessage.error(e?.response?.data?.detail || '获取完整认证信息失败')
} finally {
applyingSelection.value = false
}
}
function resetExtract() {
@@ -361,6 +491,7 @@ function disconnectWs() {
ws = null
}
wsConnected.value = false
clearFrameUrls()
}
onUnmounted(() => {
@@ -396,7 +527,7 @@ function maskValue(v: string): string {
.capture-actions { display: flex; gap: 6px; align-items: center; }
.capture-hint { color: var(--el-text-color-secondary); font-size: 0.85rem; margin: 0 0 8px; }
.capture-extra-fields {
margin-top: 8px; padding: 8px; background: var(--el-fill-color-lighter); border-radius: 6px;
margin-top: 8px; padding: 8px; background: transparent; border-radius: 6px;
}
.capture-launch-row {
display: flex; justify-content: space-between; align-items: center; margin-top: 4px;
+14 -3
View File
@@ -97,6 +97,12 @@
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item v-if="form.access_mode === 'remote_browser'" label="关联上游">
<el-select v-model="form.linked_upstream_id" clearable placeholder="选择要一键刷新凭证的上游" style="width:100%">
<el-option v-for="u in upstreamList" :key="u.id" :label="`${u.name} (${u.base_url})`" :value="u.id" />
</el-select>
<div class="form-hint">关联后可在页面查看器中一键刷新该上游的认证凭证</div>
</el-form-item>
<div class="login-section">
<div class="login-section-head">
<span>登录自动填充</span>
@@ -158,7 +164,7 @@ import {
SetUp, Reading, Cpu, DataLine, Grid, Connection,
Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageAccessMode, type CustomPageData } from '@/api'
import { customPagesApi, upstreamsApi, type CustomPageAccessMode, type CustomPageData, type UpstreamData } from '@/api'
const router = useRouter()
@@ -182,6 +188,7 @@ const iconMap: Record<string, any> = {
// ---- state ----
const list = ref<CustomPageData[]>([])
const upstreamList = ref<UpstreamData[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
@@ -206,6 +213,7 @@ type PageFormState = {
login_autofill_enabled: boolean
login_password_configured: boolean
login_password_clear: boolean
linked_upstream_id: number | null
}
const defaultForm = (): PageFormState => ({
@@ -225,6 +233,7 @@ const defaultForm = (): PageFormState => ({
login_autofill_enabled: false,
login_password_configured: false,
login_password_clear: false,
linked_upstream_id: null,
})
const form = ref(defaultForm())
const rules = {
@@ -235,8 +244,9 @@ const rules = {
async function loadList() {
loading.value = true
try {
const res = await customPagesApi.list()
list.value = res.data
const [pagesRes, upstreamsRes] = await Promise.all([customPagesApi.list(), upstreamsApi.list()])
list.value = pagesRes.data
upstreamList.value = upstreamsRes.data
} finally {
loading.value = false
}
@@ -269,6 +279,7 @@ function openEdit(page: CustomPageData) {
login_autofill_enabled: page.login_autofill_enabled,
login_password_configured: page.login_password_configured,
login_password_clear: false,
linked_upstream_id: page.linked_upstream_id ?? null,
}
dialogVisible.value = true
}
+24
View File
@@ -28,6 +28,11 @@
<el-icon><Right /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="canRefreshAuth" content="一键刷新上游凭证">
<el-button size="small" text type="warning" :loading="refreshingAuth" @click="refreshAuth">
<el-icon><Key /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="在新标签页打开">
<el-button size="small" text @click="openExternal">
<el-icon><TopRight /></el-icon>
@@ -209,6 +214,8 @@ type RemoteBrowserErrorState = {
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
const isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
const canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value)
const refreshingAuth = ref(false)
const effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
const embedded = computed(() => props.embedded)
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
@@ -430,6 +437,23 @@ async function copyRemoteSelection() {
}
}
async function refreshAuth() {
if (!page.value) return
refreshingAuth.value = true
try {
const res = await customPagesApi.refreshAuth(page.value.id)
if (res.data.success) {
ElMessage.success(res.data.message || '凭证已刷新')
} else {
ElMessage.warning(res.data.message || '刷新失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '刷新凭证失败')
} finally {
refreshingAuth.value = false
}
}
function remoteViewport() {
const rect = remoteFrameRef.value?.getBoundingClientRect()
return {
+23 -4
View File
@@ -270,8 +270,8 @@
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'login_password'">
<el-form-item label="登录邮箱">
<el-input v-model="form.auth_config.email" placeholder="admin@example.com" />
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
@@ -409,7 +409,7 @@
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="form.base_url"
:initial-url="authCaptureInitialUrl"
@select="handleAuthCaptureSelect"
/>
</div>
@@ -452,11 +452,17 @@ const rules = {
const authCaptureVisible = ref(false)
const authCaptureInitialUrl = computed(() => {
const base = (form.value.base_url || '').replace(/\/+$/, '')
if (!base) return ''
return base + '/login'
})
function openAuthCapture() {
authCaptureVisible.value = true
}
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string }) {
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string; new_api_user?: string }) {
if (candidate.type === 'bearer_token') {
form.value.auth_type = 'bearer'
form.value.auth_config.token = candidate.value
@@ -466,6 +472,17 @@ function handleAuthCaptureSelect(candidate: { type: string; value: string; cooki
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value
if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
} else if (quickPlatform.value === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
ElMessage.warning('已填入 Cookie,但未提取到 New-Api-User,请重新登录后再提取')
}
ElMessage.success('已填入 Cookie')
} else if (candidate.type === 'api_key') {
form.value.auth_type = 'api_key'
@@ -495,6 +512,7 @@ function handlePlatformChange(val: string) {
form.value.rate_endpoint = '/groups/rates'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/auth/login'
form.value.auth_config.username_field = 'email'
} else if (val === 'new-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/'
@@ -506,6 +524,7 @@ function handlePlatformChange(val: string) {
form.value.rate_endpoint = '/api/user/self/groups'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/api/user/login'
form.value.auth_config.username_field = 'username'
}
}