8 changed files with 452 additions and 0 deletions
@ -0,0 +1,196 @@ |
|||||||
|
<template> |
||||||
|
<h2 class="mb-3 text-2xl font-bold text-center xl:text-3xl enter-x xl:text-left"> |
||||||
|
{{ client.name + t('sys.login.ssoSignInFormTitle') }} |
||||||
|
</h2> |
||||||
|
<Form class="p-4 enter-x" :model="loginForm" ref="formRef" @keypress.enter="handleAuthorize(true)"> |
||||||
|
此第三方应用请求获取以下权限: |
||||||
|
<Row class="enter-x"> |
||||||
|
<Col :span="12"> |
||||||
|
<template v-for="scope in params.scopes" :key="scope"> |
||||||
|
<FormItem> |
||||||
|
<!-- No logic, you need to deal with it yourself --> |
||||||
|
<Checkbox :checked="scope" size="small"> |
||||||
|
<Button type="link" size="small"> |
||||||
|
{{ formatScope(scope) }} |
||||||
|
</Button> |
||||||
|
</Checkbox> |
||||||
|
</FormItem> |
||||||
|
</template> |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
|
||||||
|
<FormItem class="enter-x"> |
||||||
|
<Button type="primary" size="large" block @click="handleAuthorize(true)" :loading="loading"> |
||||||
|
{{ t('sys.login.loginButton') }} |
||||||
|
</Button> |
||||||
|
<Button size="large" class="mt-4 enter-x" block @click="handleAuthorize(false)"> |
||||||
|
{{ t('common.cancelText') }} |
||||||
|
</Button> |
||||||
|
</FormItem> |
||||||
|
</Form> |
||||||
|
</template> |
||||||
|
<script lang="ts" setup> |
||||||
|
import { reactive, ref } from 'vue' |
||||||
|
import { useRoute } from 'vue-router' |
||||||
|
import { Checkbox, Form, Row, Col, Button } from 'ant-design-vue' |
||||||
|
|
||||||
|
import { useI18n } from '@/hooks/web/useI18n' |
||||||
|
import { useMessage } from '@/hooks/web/useMessage' |
||||||
|
|
||||||
|
import { useFormValid } from './useLogin' |
||||||
|
import { useDesign } from '@/hooks/web/useDesign' |
||||||
|
import { authorize, getAuthorize } from '@/api/base/login' |
||||||
|
import { onMounted } from 'vue' |
||||||
|
|
||||||
|
const FormItem = Form.Item |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
const { query } = useRoute() |
||||||
|
const { notification, createErrorModal } = useMessage() |
||||||
|
const { prefixCls } = useDesign('login') |
||||||
|
|
||||||
|
const formRef = ref() |
||||||
|
const loading = ref(false) |
||||||
|
|
||||||
|
const loginForm = reactive({ |
||||||
|
scopes: [] as any[] // 已选中的 scope 数组 |
||||||
|
}) |
||||||
|
|
||||||
|
// URL 上的 client_id、scope 等参数 |
||||||
|
const params = reactive({ |
||||||
|
responseType: undefined as any, |
||||||
|
clientId: undefined as any, |
||||||
|
redirectUri: undefined as any, |
||||||
|
state: undefined as any, |
||||||
|
scopes: [] as any[] // 优先从 query 参数获取;如果未传递,从后端获取 |
||||||
|
}) |
||||||
|
|
||||||
|
// 客户端信息 |
||||||
|
let client = reactive({ |
||||||
|
name: '', |
||||||
|
logo: '' |
||||||
|
}) |
||||||
|
|
||||||
|
const { validForm } = useFormValid(formRef) |
||||||
|
|
||||||
|
async function init() { |
||||||
|
// 解析参数 |
||||||
|
// 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write |
||||||
|
// 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read |
||||||
|
params.responseType = query.response_type as any |
||||||
|
params.clientId = query.client_id as any |
||||||
|
params.redirectUri = query.redirect_uri as any |
||||||
|
params.state = query.state as any |
||||||
|
if (query.scope) { |
||||||
|
params.scopes = (query.scope as any).split(' ') |
||||||
|
} |
||||||
|
|
||||||
|
// 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。 |
||||||
|
if (params.scopes.length > 0) { |
||||||
|
const res = await doAuthorize(true, params.scopes, []) |
||||||
|
const href = res |
||||||
|
if (!href) { |
||||||
|
console.log('自动授权未通过!') |
||||||
|
return |
||||||
|
} |
||||||
|
location.href = href |
||||||
|
} |
||||||
|
|
||||||
|
// 获取授权页的基本信息 |
||||||
|
const res = await getAuthorize(params.clientId) |
||||||
|
client = res.client |
||||||
|
// 解析 scope |
||||||
|
let scopes |
||||||
|
// 1.1 如果 params.scope 非空,则过滤下返回的 scopes |
||||||
|
if (params.scopes.length > 0) { |
||||||
|
scopes = [] |
||||||
|
for (const scope of res.scopes) { |
||||||
|
if (params.scopes.indexOf(scope.key) >= 0) { |
||||||
|
scopes.push(scope) |
||||||
|
} |
||||||
|
} |
||||||
|
// 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它 |
||||||
|
} else { |
||||||
|
scopes = res.data.scopes |
||||||
|
for (const scope of scopes) { |
||||||
|
params.scopes.push(scope.key) |
||||||
|
} |
||||||
|
} |
||||||
|
// 生成已选中的 checkedScopes |
||||||
|
for (const scope of scopes) { |
||||||
|
if (scope.value) { |
||||||
|
loginForm.scopes.push(scope.key) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function handleAuthorize(approved) { |
||||||
|
const data = await validForm() |
||||||
|
if (!data) return |
||||||
|
try { |
||||||
|
loading.value = true |
||||||
|
// 计算 checkedScopes + uncheckedScopes |
||||||
|
let checkedScopes |
||||||
|
let uncheckedScopes |
||||||
|
if (approved) { |
||||||
|
// 同意授权,按照用户的选择 |
||||||
|
checkedScopes = loginForm.scopes |
||||||
|
uncheckedScopes = params.scopes.filter((item) => checkedScopes.indexOf(item) === -1) |
||||||
|
} else { |
||||||
|
// 拒绝,则都是取消 |
||||||
|
checkedScopes = [] |
||||||
|
uncheckedScopes = params.scopes |
||||||
|
} |
||||||
|
// 提交授权的请求 |
||||||
|
const res = await doAuthorize(false, checkedScopes, uncheckedScopes) |
||||||
|
if (res) { |
||||||
|
const href = res |
||||||
|
if (!href) { |
||||||
|
return |
||||||
|
} |
||||||
|
location.href = href |
||||||
|
notification.success({ |
||||||
|
message: t('sys.login.loginSuccessTitle'), |
||||||
|
description: `${t('sys.login.loginSuccessDesc')}`, |
||||||
|
duration: 3 |
||||||
|
}) |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
createErrorModal({ |
||||||
|
title: t('sys.api.errorTip'), |
||||||
|
content: (error as unknown as Error).message || t('sys.api.networkExceptionMsg'), |
||||||
|
getContainer: () => document.body.querySelector(`.${prefixCls}`) || document.body |
||||||
|
}) |
||||||
|
} finally { |
||||||
|
loading.value = false |
||||||
|
} |
||||||
|
} |
||||||
|
async function doAuthorize(autoApprove, checkedScopes, uncheckedScopes) { |
||||||
|
return await authorize( |
||||||
|
params.responseType, |
||||||
|
params.clientId, |
||||||
|
params.redirectUri, |
||||||
|
params.state, |
||||||
|
autoApprove, |
||||||
|
checkedScopes, |
||||||
|
uncheckedScopes |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function formatScope(scope) { |
||||||
|
// 格式化 scope 授权范围,方便用户理解。 |
||||||
|
// 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。 |
||||||
|
switch (scope) { |
||||||
|
case 'user.read': |
||||||
|
return t('sys.login.ssoInfoDesc') |
||||||
|
case 'user.write': |
||||||
|
return t('sys.login.ssoEditDesc') |
||||||
|
default: |
||||||
|
return scope |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
init() |
||||||
|
}) |
||||||
|
</script> |
@ -0,0 +1,204 @@ |
|||||||
|
<template> |
||||||
|
<div :class="prefixCls" class="relative w-full h-full px-4"> |
||||||
|
<div class="flex items-center absolute right-4 top-4"> |
||||||
|
<AppDarkModeToggle class="enter-x mr-2" v-if="!sessionTimeout" /> |
||||||
|
<AppLocalePicker class="text-white enter-x xl:text-gray-600" :show-text="false" v-if="!sessionTimeout && showLocale" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<span class="-enter-x xl:hidden"> |
||||||
|
<AppLogo :alwaysShowTitle="true" /> |
||||||
|
</span> |
||||||
|
|
||||||
|
<div class="container relative h-full py-2 mx-auto sm:px-10"> |
||||||
|
<div class="flex h-full"> |
||||||
|
<div class="hidden min-h-full pl-4 mr-4 xl:flex xl:flex-col xl:w-6/12"> |
||||||
|
<AppLogo class="-enter-x" /> |
||||||
|
<div class="my-auto"> |
||||||
|
<img :alt="title" src="@/assets/svg/login-box-bg.svg" class="w-1/2 -mt-16 -enter-x" /> |
||||||
|
<div class="mt-10 font-medium text-white -enter-x"> |
||||||
|
<span class="inline-block mt-4 text-3xl"> {{ t('sys.login.signInTitle') }}</span> |
||||||
|
</div> |
||||||
|
<div class="mt-5 font-normal text-white dark:text-gray-500 -enter-x"> |
||||||
|
{{ t('sys.login.signInDesc') }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="flex w-full h-full py-5 xl:h-auto xl:py-0 xl:my-0 xl:w-6/12"> |
||||||
|
<!-- eslint-disable max-len --> |
||||||
|
<div |
||||||
|
:class="`${prefixCls}-form`" |
||||||
|
class="relative w-full px-5 py-8 mx-auto my-auto rounded-md shadow-md xl:ml-16 xl:bg-transparent sm:px-8 xl:p-4 xl:shadow-none sm:w-3/4 lg:w-2/4 xl:w-auto enter-x" |
||||||
|
> |
||||||
|
<SSOForm /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<script lang="ts" setup> |
||||||
|
import { computed } from 'vue' |
||||||
|
import { AppLogo } from '@/components/Application' |
||||||
|
import { AppLocalePicker, AppDarkModeToggle } from '@/components/Application' |
||||||
|
import SSOForm from './SSOForm.vue' |
||||||
|
import { useGlobSetting } from '@/hooks/setting' |
||||||
|
import { useI18n } from '@/hooks/web/useI18n' |
||||||
|
import { useDesign } from '@/hooks/web/useDesign' |
||||||
|
import { useLocaleStore } from '@/store/modules/locale' |
||||||
|
|
||||||
|
defineProps({ |
||||||
|
sessionTimeout: { |
||||||
|
type: Boolean |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const globSetting = useGlobSetting() |
||||||
|
const { prefixCls } = useDesign('login') |
||||||
|
const { t } = useI18n() |
||||||
|
const localeStore = useLocaleStore() |
||||||
|
const showLocale = localeStore.getShowPicker |
||||||
|
const title = computed(() => globSetting?.title ?? '') |
||||||
|
</script> |
||||||
|
<style lang="less"> |
||||||
|
@prefix-cls: ~'@{namespace}-login'; |
||||||
|
@logo-prefix-cls: ~'@{namespace}-app-logo'; |
||||||
|
@countdown-prefix-cls: ~'@{namespace}-countdown-input'; |
||||||
|
@dark-bg: #293146; |
||||||
|
|
||||||
|
html[data-theme='dark'] { |
||||||
|
.@{prefix-cls} { |
||||||
|
background-color: @dark-bg; |
||||||
|
|
||||||
|
&::before { |
||||||
|
background-image: url('@/assets/svg/login-bg-dark.svg'); |
||||||
|
} |
||||||
|
|
||||||
|
.ant-input, |
||||||
|
.ant-input-password { |
||||||
|
background-color: #232a3b; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-btn:not(.ant-btn-link, .ant-btn-primary) { |
||||||
|
border: 1px solid #4a5569; |
||||||
|
} |
||||||
|
|
||||||
|
&-form { |
||||||
|
background: transparent !important; |
||||||
|
} |
||||||
|
|
||||||
|
.app-iconify { |
||||||
|
color: #fff; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
input.fix-auto-fill, |
||||||
|
.fix-auto-fill input { |
||||||
|
-webkit-text-fill-color: #c9d1d9 !important; |
||||||
|
box-shadow: inherit !important; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.@{prefix-cls} { |
||||||
|
min-height: 100%; |
||||||
|
overflow: hidden; |
||||||
|
|
||||||
|
@media (max-width: @screen-xl) { |
||||||
|
background-color: #293146; |
||||||
|
|
||||||
|
.@{prefix-cls}-form { |
||||||
|
background-color: #fff; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
&::before { |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
margin-left: -48%; |
||||||
|
background-image: url('@/assets/svg/login-bg.svg'); |
||||||
|
background-position: 100%; |
||||||
|
background-repeat: no-repeat; |
||||||
|
background-size: auto 100%; |
||||||
|
content: ''; |
||||||
|
|
||||||
|
@media (max-width: @screen-xl) { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.@{logo-prefix-cls} { |
||||||
|
position: absolute; |
||||||
|
top: 12px; |
||||||
|
height: 30px; |
||||||
|
|
||||||
|
&__title { |
||||||
|
font-size: 16px; |
||||||
|
color: #fff; |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
width: 32px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.container { |
||||||
|
.@{logo-prefix-cls} { |
||||||
|
display: flex; |
||||||
|
width: 60%; |
||||||
|
height: 80px; |
||||||
|
|
||||||
|
&__title { |
||||||
|
font-size: 24px; |
||||||
|
color: #fff; |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
width: 48px; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
&-sign-in-way { |
||||||
|
.anticon { |
||||||
|
font-size: 22px; |
||||||
|
color: #888; |
||||||
|
cursor: pointer; |
||||||
|
|
||||||
|
&:hover { |
||||||
|
color: @primary-color; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
input:not([type='checkbox']) { |
||||||
|
min-width: 360px; |
||||||
|
|
||||||
|
@media (max-width: @screen-xl) { |
||||||
|
min-width: 320px; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: @screen-lg) { |
||||||
|
min-width: 260px; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: @screen-md) { |
||||||
|
min-width: 240px; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: @screen-sm) { |
||||||
|
min-width: 160px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.@{countdown-prefix-cls} input { |
||||||
|
min-width: unset; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-divider-inner-text { |
||||||
|
font-size: 12px; |
||||||
|
color: @text-color-secondary; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
Reference in new issue