@@ -182,7 +182,11 @@ function rowProps(row: AuditEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 原始 JSON 加载代次:连续点开不同行时,先点行的迟到响应不得盖住后点行的详情
|
||||
let rawSeq = 0
|
||||
|
||||
async function loadRaw(row: AuditEvent) {
|
||||
const seq = ++rawSeq
|
||||
detailRaw.value = null
|
||||
rawGone.value = false
|
||||
if (!row.eventId || !row.eventTime) {
|
||||
@@ -195,11 +199,11 @@ async function loadRaw(row: AuditEvent) {
|
||||
eventId: row.eventId,
|
||||
eventTime: row.eventTime,
|
||||
})
|
||||
detailRaw.value = raw
|
||||
if (seq === rawSeq) detailRaw.value = raw
|
||||
} catch {
|
||||
rawGone.value = true
|
||||
if (seq === rawSeq) rawGone.value = true
|
||||
} finally {
|
||||
rawLoading.value = false
|
||||
if (seq === rawSeq) rawLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
||||
import { NButton, NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
|
||||
import {
|
||||
createIdp,
|
||||
deleteIdpIcon,
|
||||
getIdentitySetting,
|
||||
uploadIdpIcon,
|
||||
type CreateIdpBody,
|
||||
} from '@/api/tenant'
|
||||
import FilePicker from '@/components/FilePicker.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { IdpIconUpload } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
||||
@@ -15,6 +22,7 @@ const message = useToast()
|
||||
const submitting = ref(false)
|
||||
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
|
||||
const primaryEmailRequired = ref(false)
|
||||
const identitySettingReady = ref(false)
|
||||
|
||||
const nameIdFormatOptions = [
|
||||
{ label: '无', value: 'saml-none' },
|
||||
@@ -51,22 +59,66 @@ const blank = {
|
||||
}
|
||||
const form = reactive({ ...blank })
|
||||
|
||||
// ---- 图标上传:传到身份域公共图片存储(/storage/v1/Images),回填公网 URL ----
|
||||
const iconFileEl = ref<HTMLInputElement | null>(null)
|
||||
const iconUploading = ref(false)
|
||||
// 最近上传且尚未被创建采用的图标;替换、放弃表单或提交未引用时尽力删除。
|
||||
// 删除失败只会在自己租户里留下一张无入口的小图,静默接受,不打扰用户。
|
||||
let pendingIcon: (IdpIconUpload & { cfgId: number; domainId?: string }) | null = null
|
||||
let identitySettingSeq = 0
|
||||
// 图片加载失败时回退占位,避免预览框里出现浏览器裂图
|
||||
const iconError = ref(false)
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
Object.assign(form, blank)
|
||||
void loadIdentitySetting()
|
||||
},
|
||||
() => form.iconUrl,
|
||||
() => (iconError.value = false),
|
||||
)
|
||||
// 上传请求序号:重开弹窗或重新选文件即自增,旧请求的响应按序号丢弃
|
||||
let iconUploadSeq = 0
|
||||
|
||||
function invalidateIconUpload() {
|
||||
iconUploadSeq++
|
||||
iconUploading.value = false
|
||||
}
|
||||
|
||||
function cleanupPendingIcon() {
|
||||
const icon = pendingIcon
|
||||
pendingIcon = null
|
||||
if (icon) void deleteIdpIcon(icon.cfgId, icon.fileName, icon.domainId).catch(() => {})
|
||||
}
|
||||
|
||||
function abandonForm() {
|
||||
identitySettingSeq++
|
||||
identitySettingReady.value = false
|
||||
invalidateIconUpload()
|
||||
cleanupPendingIcon()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
abandonForm()
|
||||
primaryEmailRequired.value = false
|
||||
Object.assign(form, blank)
|
||||
void loadIdentitySetting()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.cfgId, props.domainId] as const,
|
||||
([show]) => (show ? resetForm() : abandonForm()),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// show/cfgId/domainId 任一变化都会经 abandonForm 递增序号,故过期只需比对序号
|
||||
async function loadIdentitySetting() {
|
||||
const seq = ++identitySettingSeq
|
||||
identitySettingReady.value = false
|
||||
try {
|
||||
const s = await getIdentitySetting(props.cfgId)
|
||||
const s = await getIdentitySetting(props.cfgId, props.domainId)
|
||||
if (seq !== identitySettingSeq) return
|
||||
primaryEmailRequired.value = s.primaryEmailRequired
|
||||
if (s.primaryEmailRequired) form.jitMapEmail = true
|
||||
} catch {
|
||||
primaryEmailRequired.value = false
|
||||
if (seq === identitySettingSeq) primaryEmailRequired.value = false
|
||||
} finally {
|
||||
if (seq === identitySettingSeq) identitySettingReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +128,60 @@ const iconUrlValid = computed(() => {
|
||||
return !v || /^https?:\/\//.test(v)
|
||||
})
|
||||
|
||||
async function onIconPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || submitting.value) return
|
||||
if (file.size > 1 << 20) {
|
||||
message.error('图标不能超过 1MB')
|
||||
return
|
||||
}
|
||||
const { cfgId, domainId } = props
|
||||
const seq = ++iconUploadSeq
|
||||
iconUploading.value = true
|
||||
try {
|
||||
const result = await uploadIdpIcon(cfgId, file, domainId)
|
||||
if (seq !== iconUploadSeq) {
|
||||
void deleteIdpIcon(cfgId, result.fileName, domainId).catch(() => {})
|
||||
return
|
||||
}
|
||||
cleanupPendingIcon()
|
||||
pendingIcon = { ...result, cfgId, domainId }
|
||||
form.iconUrl = result.url
|
||||
message.success('图标已上传到身份域公共存储')
|
||||
} catch (err) {
|
||||
if (seq === iconUploadSeq) message.error(err instanceof Error ? err.message : '上传失败')
|
||||
} finally {
|
||||
if (seq === iconUploadSeq) iconUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onIconUrlInput(value: string) {
|
||||
if (submitting.value || value === form.iconUrl) return
|
||||
invalidateIconUpload()
|
||||
form.iconUrl = value
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
abandonForm()
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function onModalShowChange(show: boolean) {
|
||||
if (!show && submitting.value) return
|
||||
if (show) emit('update:show', true)
|
||||
else closeForm()
|
||||
}
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!!form.name.trim() &&
|
||||
form.metadata.includes('EntityDescriptor') &&
|
||||
iconUrlValid.value &&
|
||||
!iconUploading.value &&
|
||||
identitySettingReady.value &&
|
||||
!submitting.value &&
|
||||
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
|
||||
)
|
||||
|
||||
@@ -102,11 +203,18 @@ function buildBody(): CreateIdpBody {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value || !canSubmit.value) return
|
||||
const body = buildBody()
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
|
||||
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
||||
emit('update:show', false)
|
||||
const created = await createIdp(props.cfgId, body, props.domainId)
|
||||
// 创建请求引用了刚上传的图标即视为已采用,交给 IdP 生命周期,不再清理
|
||||
if (pendingIcon && pendingIcon.url === body.iconUrl) pendingIcon = null
|
||||
const warning = created.setupWarning
|
||||
if (warning)
|
||||
message.warning(`IdP「${created.name}」的创建结果需确认`, `${warning.message}\n关联 ID:${warning.requestId}`)
|
||||
else message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
||||
closeForm()
|
||||
emit('created')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
@@ -124,12 +232,17 @@ async function submit() {
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!canSubmit"
|
||||
submit-text="创建(禁用态)"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@update:show="onModalShowChange"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField label="名称" required>
|
||||
<NInput v-model:value="form.name" placeholder="my-idp" />
|
||||
</FormField>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="名称" required>
|
||||
<NInput v-model:value="form.name" placeholder="my-idp" />
|
||||
</FormField>
|
||||
<FormField label="备注">
|
||||
<NInput v-model:value="form.description" placeholder="可选" />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="IdP SAML metadata XML 文件"
|
||||
required
|
||||
@@ -153,26 +266,49 @@ async function submit() {
|
||||
@load="(text) => (form.metadata = text)"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="备注">
|
||||
<NInput v-model:value="form.description" placeholder="可选" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Logo(登录页图标)"
|
||||
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
|
||||
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FormField
|
||||
label="Logo(登录页图标)"
|
||||
hint="展示在登录页 IdP 按钮上;可上传到身份域公共存储(≤1MB),或直接填 http(s) 外链 URL"
|
||||
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-[34px] w-[34px] flex-none items-center justify-center overflow-hidden rounded border border-line-soft bg-wash"
|
||||
>
|
||||
<img
|
||||
v-if="form.iconUrl && iconUrlValid"
|
||||
v-if="form.iconUrl && iconUrlValid && !iconError"
|
||||
:src="form.iconUrl"
|
||||
alt=""
|
||||
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
|
||||
class="h-full w-full object-contain"
|
||||
@error="iconError = true"
|
||||
/>
|
||||
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
|
||||
<span v-else class="text-[10px] text-ink-3 select-none">Logo</span>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<NInput
|
||||
:value="form.iconUrl"
|
||||
:disabled="submitting"
|
||||
placeholder="https://…/logo.png,或点右侧上传"
|
||||
class="min-w-0 flex-1"
|
||||
@update:value="onIconUrlInput"
|
||||
/>
|
||||
<NButton
|
||||
class="flex-none"
|
||||
:loading="iconUploading"
|
||||
:disabled="submitting"
|
||||
@click="iconFileEl?.click()"
|
||||
>
|
||||
上传
|
||||
</NButton>
|
||||
<input
|
||||
ref="iconFileEl"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
|
||||
:disabled="submitting"
|
||||
class="hidden"
|
||||
@change="onIconPick"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
|
||||
映射用户身份
|
||||
|
||||
Reference in New Issue
Block a user