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:
co-authored by
Claude Opus 4.7
parent
7cb0ff1608
commit
4c71148ff9
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user