Login.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. <script setup lang="ts">
  2. import auth from '@/api/auth'
  3. import install from '@/api/install'
  4. import passkey from '@/api/passkey'
  5. import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
  6. import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
  7. import Authorization from '@/components/TwoFA/Authorization.vue'
  8. import gettext from '@/gettext'
  9. import { useUserStore } from '@/pinia'
  10. import { KeyOutlined, LockOutlined, UserOutlined } from '@ant-design/icons-vue'
  11. import { startAuthentication } from '@simplewebauthn/browser'
  12. import { Form, message } from 'ant-design-vue'
  13. const thisYear = new Date().getFullYear()
  14. const route = useRoute()
  15. const router = useRouter()
  16. install.get_lock().then(async (r: { lock: boolean }) => {
  17. if (!r.lock)
  18. await router.push('/install')
  19. })
  20. const loading = ref(false)
  21. const enabled2FA = ref(false)
  22. const refOTP = ref()
  23. const passcode = ref('')
  24. const recoveryCode = ref('')
  25. const passkeyConfigStatus = ref(false)
  26. const modelRef = reactive({
  27. username: '',
  28. password: '',
  29. })
  30. const rulesRef = reactive({
  31. username: [
  32. {
  33. required: true,
  34. message: () => $gettext('Please input your username!'),
  35. },
  36. ],
  37. password: [
  38. {
  39. required: true,
  40. message: () => $gettext('Please input your password!'),
  41. },
  42. ],
  43. })
  44. const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
  45. const userStore = useUserStore()
  46. const { login, passkeyLogin } = userStore
  47. const { secureSessionId } = storeToRefs(userStore)
  48. function onSubmit() {
  49. validate().then(async () => {
  50. loading.value = true
  51. await auth.login(modelRef.username, modelRef.password, passcode.value, recoveryCode.value).then(async r => {
  52. const next = (route.query?.next || '').toString() || '/'
  53. switch (r.code) {
  54. case 200:
  55. message.success($gettext('Login successful'), 1)
  56. login(r.token)
  57. await nextTick()
  58. secureSessionId.value = r.secure_session_id
  59. await router.push(next)
  60. break
  61. case 199:
  62. enabled2FA.value = true
  63. break
  64. }
  65. }).catch(e => {
  66. switch (e.code) {
  67. case 4031:
  68. message.error($gettext('Incorrect username or password'))
  69. break
  70. case 4291:
  71. message.error($gettext('Too many login failed attempts, please try again later'))
  72. break
  73. case 4033:
  74. message.error($gettext('User is banned'))
  75. break
  76. case 4034:
  77. refOTP.value?.clearInput()
  78. message.error($gettext('Invalid 2FA or recovery code'))
  79. break
  80. default:
  81. message.error($gettext(e.message ?? 'Server error'))
  82. break
  83. }
  84. })
  85. loading.value = false
  86. })
  87. }
  88. const user = useUserStore()
  89. if (user.isLogin) {
  90. const next = (route.query?.next || '').toString() || '/dashboard'
  91. router.push(next)
  92. }
  93. watch(() => gettext.current, () => {
  94. clearValidate()
  95. })
  96. const has_casdoor = ref(false)
  97. const casdoor_uri = ref('')
  98. auth.get_casdoor_uri()
  99. .then(r => {
  100. if (r?.uri) {
  101. has_casdoor.value = true
  102. casdoor_uri.value = r.uri
  103. }
  104. })
  105. .catch(e => {
  106. message.error($gettext(e.message ?? 'Server error'))
  107. })
  108. function loginWithCasdoor() {
  109. window.location.href = casdoor_uri.value
  110. }
  111. if (route.query?.code !== undefined && route.query?.state !== undefined) {
  112. loading.value = true
  113. auth.casdoor_login(route.query?.code?.toString(), route.query?.state?.toString()).then(async () => {
  114. message.success($gettext('Login successful'), 1)
  115. const next = (route.query?.next || '').toString() || '/'
  116. await router.push(next)
  117. }).catch(e => {
  118. message.error($gettext(e.message ?? 'Server error'))
  119. })
  120. loading.value = false
  121. }
  122. function handleOTPSubmit(code: string, recovery: string) {
  123. passcode.value = code
  124. recoveryCode.value = recovery
  125. nextTick(() => {
  126. onSubmit()
  127. })
  128. }
  129. passkey.get_config_status().then(r => {
  130. passkeyConfigStatus.value = r.status
  131. })
  132. const passkeyLoginLoading = ref(false)
  133. async function handlePasskeyLogin() {
  134. passkeyLoginLoading.value = true
  135. try {
  136. const begin = await auth.begin_passkey_login()
  137. const asseResp = await startAuthentication({ optionsJSON: begin.options.publicKey })
  138. const r = await auth.finish_passkey_login({
  139. session_id: begin.session_id,
  140. options: asseResp,
  141. })
  142. if (r.token) {
  143. const next = (route.query?.next || '').toString() || '/'
  144. passkeyLogin(asseResp.rawId, r.token)
  145. secureSessionId.value = r.secure_session_id
  146. await router.push(next)
  147. }
  148. }
  149. // eslint-disable-next-line ts/no-explicit-any
  150. catch (e: any) {
  151. message.error($gettext(e.message ?? 'Server error'))
  152. }
  153. passkeyLoginLoading.value = false
  154. }
  155. </script>
  156. <template>
  157. <ALayout>
  158. <ALayoutContent>
  159. <div class="login-container">
  160. <div class="login-form">
  161. <div class="project-title">
  162. <h1>Nginx UI</h1>
  163. </div>
  164. <AForm id="components-form-demo-normal-login">
  165. <template v-if="!enabled2FA">
  166. <AFormItem v-bind="validateInfos.username">
  167. <AInput
  168. v-model:value="modelRef.username"
  169. :placeholder="$gettext('Username')"
  170. >
  171. <template #prefix>
  172. <UserOutlined style="color: rgba(0, 0, 0, 0.25)" />
  173. </template>
  174. </AInput>
  175. </AFormItem>
  176. <AFormItem v-bind="validateInfos.password">
  177. <AInputPassword
  178. v-model:value="modelRef.password"
  179. :placeholder="$gettext('Password')"
  180. >
  181. <template #prefix>
  182. <LockOutlined style="color: rgba(0, 0, 0, 0.25)" />
  183. </template>
  184. </AInputPassword>
  185. </AFormItem>
  186. <AButton
  187. v-if="has_casdoor"
  188. block
  189. html-type="submit"
  190. :loading="loading"
  191. class="mb-5"
  192. @click="loginWithCasdoor"
  193. >
  194. {{ $gettext('SSO Login') }}
  195. </AButton>
  196. </template>
  197. <div v-else>
  198. <Authorization
  199. ref="refOTP"
  200. :two-f-a-status="{
  201. enabled: true,
  202. otp_status: true,
  203. passkey_status: false,
  204. }"
  205. @submit-o-t-p="handleOTPSubmit"
  206. />
  207. </div>
  208. <AFormItem v-if="!enabled2FA">
  209. <AButton
  210. type="primary"
  211. block
  212. html-type="submit"
  213. :loading="loading"
  214. class="mb-2"
  215. @click="onSubmit"
  216. >
  217. {{ $gettext('Login') }}
  218. </AButton>
  219. <div
  220. v-if="passkeyConfigStatus"
  221. class="flex flex-col justify-center"
  222. >
  223. <ADivider>
  224. <div class="text-sm font-normal opacity-75">
  225. {{ $gettext('Or') }}
  226. </div>
  227. </ADivider>
  228. <AButton
  229. :loading="passkeyLoginLoading"
  230. @click="handlePasskeyLogin"
  231. >
  232. <KeyOutlined />
  233. {{ $gettext('Sign in with a passkey') }}
  234. </AButton>
  235. </div>
  236. </AFormItem>
  237. </AForm>
  238. <div class="footer">
  239. <p>Copyright © 2021 - {{ thisYear }} Nginx UI</p>
  240. Language
  241. <SetLanguage class="inline" />
  242. <div class="flex justify-center mt-4">
  243. <SwitchAppearance />
  244. </div>
  245. </div>
  246. </div>
  247. </div>
  248. </ALayoutContent>
  249. </ALayout>
  250. </template>
  251. <style lang="less" scoped>
  252. .ant-layout-content {
  253. background: #fff;
  254. }
  255. .dark .ant-layout-content {
  256. background: transparent;
  257. }
  258. .login-container {
  259. display: flex;
  260. align-items: center;
  261. justify-content: center;
  262. height: 100vh;
  263. .login-form {
  264. max-width: 400px;
  265. width: 80%;
  266. .project-title {
  267. margin: 50px;
  268. h1 {
  269. font-size: 50px;
  270. font-weight: 100;
  271. text-align: center;
  272. }
  273. }
  274. .anticon {
  275. color: #a8a5a5 !important;
  276. }
  277. .footer {
  278. padding: 30px;
  279. text-align: center;
  280. font-size: 14px;
  281. }
  282. }
  283. }
  284. </style>