220 lines
7.8 KiB
Vue
220 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
|
||
import { createIdp, getIdentitySetting, 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'
|
||
|
||
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
|
||
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
||
|
||
const message = useToast()
|
||
const submitting = ref(false)
|
||
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
|
||
const primaryEmailRequired = ref(false)
|
||
|
||
const nameIdFormatOptions = [
|
||
{ label: '无', value: 'saml-none' },
|
||
{ label: '电子邮件地址', value: 'saml-emailaddress' },
|
||
{ label: '持久标识符', value: 'saml-persistent' },
|
||
{ label: '临时标识符', value: 'saml-transient' },
|
||
{ label: '未指定', value: 'saml-unspecified' },
|
||
]
|
||
const idpAttrOptions = [
|
||
{ label: 'SAML 断言名称 ID(NameID)', value: 'nameid' },
|
||
{ label: '断言属性', value: 'attribute' },
|
||
]
|
||
const userAttrOptions = [
|
||
{ label: '用户名(userName)', value: 'userName' },
|
||
{ label: '主电子邮件(emails.primary.value)', value: 'emails.primary.value' },
|
||
]
|
||
|
||
// 默认值与控制台一致:名称 ID 格式「无」、NameID 映射用户名、
|
||
// JIT 开启建用户不更新、分配租户管理员组
|
||
const blank = {
|
||
name: '',
|
||
metadata: '',
|
||
description: '',
|
||
iconUrl: '',
|
||
nameIdFormat: 'saml-none',
|
||
idpUserAttr: 'nameid',
|
||
assertionAttr: '',
|
||
userStoreAttribute: 'userName',
|
||
jitEnabled: true,
|
||
jitCreate: true,
|
||
jitUpdate: false,
|
||
jitAssignAdminGroup: true,
|
||
jitMapEmail: false,
|
||
}
|
||
const form = reactive({ ...blank })
|
||
|
||
watch(
|
||
() => props.show,
|
||
(v) => {
|
||
if (!v) return
|
||
Object.assign(form, blank)
|
||
void loadIdentitySetting()
|
||
},
|
||
)
|
||
|
||
async function loadIdentitySetting() {
|
||
try {
|
||
const s = await getIdentitySetting(props.cfgId)
|
||
primaryEmailRequired.value = s.primaryEmailRequired
|
||
if (s.primaryEmailRequired) form.jitMapEmail = true
|
||
} catch {
|
||
primaryEmailRequired.value = false
|
||
}
|
||
}
|
||
|
||
// IDCS 只接受 http(s) 外链图标,data URI 会被拒(error.saml.identityprovider.invalidIconUrl)
|
||
const iconUrlValid = computed(() => {
|
||
const v = form.iconUrl.trim()
|
||
return !v || /^https?:\/\//.test(v)
|
||
})
|
||
|
||
const canSubmit = computed(
|
||
() =>
|
||
!!form.name.trim() &&
|
||
form.metadata.includes('EntityDescriptor') &&
|
||
iconUrlValid.value &&
|
||
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
|
||
)
|
||
|
||
function buildBody(): CreateIdpBody {
|
||
return {
|
||
name: form.name.trim(),
|
||
metadata: form.metadata,
|
||
description: form.description.trim() || undefined,
|
||
iconUrl: form.iconUrl.trim() || undefined,
|
||
nameIdFormat: form.nameIdFormat,
|
||
assertionAttribute: form.idpUserAttr === 'attribute' ? form.assertionAttr.trim() : undefined,
|
||
userStoreAttribute: form.userStoreAttribute,
|
||
jitEnabled: form.jitEnabled,
|
||
jitCreate: form.jitCreate,
|
||
jitUpdate: form.jitUpdate,
|
||
jitAssignAdminGroup: form.jitAssignAdminGroup,
|
||
jitMapEmail: form.jitEnabled && form.jitMapEmail,
|
||
}
|
||
}
|
||
|
||
async function submit() {
|
||
submitting.value = true
|
||
try {
|
||
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
|
||
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
||
emit('update:show', false)
|
||
emit('created')
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '创建失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<FormModal
|
||
:show="show"
|
||
title="创建 SAML IdP"
|
||
:width="640"
|
||
:submitting="submitting"
|
||
:submit-disabled="!canSubmit"
|
||
submit-text="创建(禁用态)"
|
||
@update:show="emit('update:show', $event)"
|
||
@submit="submit"
|
||
>
|
||
<FormField label="名称" required>
|
||
<NInput v-model:value="form.name" placeholder="my-idp" />
|
||
</FormField>
|
||
<FormField
|
||
label="IdP SAML metadata XML 文件"
|
||
required
|
||
hint="IdP 侧导出的 metadata XML;先在本页「下载 SP 元数据」导入 IdP 侧再创建"
|
||
:error="
|
||
form.metadata && !form.metadata.includes('EntityDescriptor')
|
||
? '文件内容不含 EntityDescriptor,请确认选择的是 SAML metadata XML'
|
||
: undefined
|
||
"
|
||
:feedback="
|
||
form.metadata.includes('EntityDescriptor')
|
||
? `已读取 ${(form.metadata.length / 1024).toFixed(1)} KB`
|
||
: undefined
|
||
"
|
||
>
|
||
<FilePicker
|
||
:key="`md-${show}`"
|
||
label="拖拽或点击上传 SAML metadata 文件"
|
||
hint="支持 .xml,文件内容仅本地读取"
|
||
accept=".xml"
|
||
@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">
|
||
<img
|
||
v-if="form.iconUrl && iconUrlValid"
|
||
:src="form.iconUrl"
|
||
alt=""
|
||
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
|
||
/>
|
||
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
|
||
</div>
|
||
</FormField>
|
||
</div>
|
||
|
||
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
|
||
映射用户身份
|
||
</div>
|
||
<FormField label="请求的名称 ID 格式" hint="向 IdP 请求断言时期望的 NameID 格式">
|
||
<NSelect v-model:value="form.nameIdFormat" :options="nameIdFormatOptions" />
|
||
</FormField>
|
||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||
<FormField label="IdP 用户属性" hint="从 SAML 断言中取哪个值来识别用户">
|
||
<NSelect v-model:value="form.idpUserAttr" :options="idpAttrOptions" />
|
||
</FormField>
|
||
<FormField label="域用户属性" hint="用取到的值匹配身份域中用户的哪个属性">
|
||
<NSelect v-model:value="form.userStoreAttribute" :options="userAttrOptions" />
|
||
</FormField>
|
||
</div>
|
||
<FormField v-if="form.idpUserAttr === 'attribute'" label="断言属性名" required>
|
||
<NInput v-model:value="form.assertionAttr" placeholder="如 mail、uid" />
|
||
</FormField>
|
||
|
||
<div class="mt-1 mb-2 flex items-center justify-between border-t border-line-soft pt-3">
|
||
<span class="text-[12.5px] font-semibold">JIT 预配</span>
|
||
<NSwitch v-model:value="form.jitEnabled" size="small" />
|
||
</div>
|
||
<div v-if="form.jitEnabled" class="mb-2 flex flex-col gap-1.5">
|
||
<NCheckbox v-model:checked="form.jitCreate">首次联邦登录时自动创建用户</NCheckbox>
|
||
<NCheckbox v-model:checked="form.jitUpdate">每次登录时按断言更新既有用户</NCheckbox>
|
||
<NCheckbox v-model:checked="form.jitAssignAdminGroup">
|
||
将 JIT 用户加入租户管理员组
|
||
</NCheckbox>
|
||
<NCheckbox
|
||
v-model:checked="form.jitMapEmail"
|
||
:disabled="primaryEmailRequired"
|
||
>
|
||
映射邮箱属性(断言 email → 主电子邮件)
|
||
<span v-if="primaryEmailRequired" class="text-xs text-ink-3">
|
||
— 域已开启「用户需要提供主电子邮件地址」,必须映射
|
||
</span>
|
||
</NCheckbox>
|
||
</div>
|
||
<div class="text-xs leading-relaxed text-ink-3">
|
||
创建后为禁用态,需手动激活;映射与 JIT 均按上方配置写入身份域。
|
||
</div>
|
||
</FormModal>
|
||
</template>
|