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