main
ts 188 lines 8.65 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { apiCall } from '@hfs/shared/api'
4 import { state, useSnapState } from './state'
5 import { alertDialog, newDialog, toast } from './dialog'
6 import {
7 getHFS, hIcon, makeSessionRefresher, working, fallbackToBasicAuth, hfsEvent, withSrpLib,
8 HTTP_CONFLICT, HTTP_UNAUTHORIZED, HTTP_METHOD_NOT_ALLOWED, ALLOW_SESSION_IP_CHANGE,
9 } from './misc'
10 import { createElement as h, Fragment, useEffect, useRef, useState } from 'react'
11 import { reloadList } from './useFetchList'
12 import { Checkbox, CustomCode } from './components'
13 import { changePassword } from './UserPanel'
14 import { srpClientSequence } from '../../src/srp'
15 import _ from 'lodash'
16 import i18n from './i18n'
17 const { t, useI18N } = i18n
18
19 async function login(username:string, password:string, extra?: object) {
20 const stopWorking = working()
21 return withSrpLib(srpClientSequence)(username, password, apiCall, extra).catch(err => {
22 if (err.code == HTTP_METHOD_NOT_ALLOWED || !password) // allow alternative authentications without a password
23 return apiCall('login', { username, password, ...extra })
24 throw err
25 }).then(res => {
26 hfsEvent('loginOk', { username })
27 refreshSession(res)
28 state.loginRequired = false
29 return res
30 }, err => {
31 hfsEvent('loginFailed', { username, error: err }) // the name inconsistency with the backend event 'failedLogin' can make it easier to distinguish
32 throw Error(err === 'trust' ? t('login_untrusted', "Login aborted: server identity cannot be trusted")
33 : err.code === HTTP_UNAUTHORIZED && !err.data ? t('login_bad_credentials', "Invalid credentials") // err.data is empty on standard errors, but a plugin may want to show differently
34 : err.code === HTTP_CONFLICT ? t('login_bad_cookies', "Cookies are not working - login failed")
35 : t(err.message || String(err)) )
36 }).finally(stopWorking)
37 }
38
39 const refreshSession = makeSessionRefresher(state)
40
41 export function logout() {
42 return apiCall('logout', {}, { modal: working }).catch(res => {
43 if (res.code !== HTTP_UNAUTHORIZED) // we expect this error code
44 throw res
45 refreshSession()
46 if (fallbackToBasicAuth())
47 return location.reload() // reloading avoids nasty warnings with ff52
48 reloadList()
49 toast(t`Logged out`, 'success')
50 })
51 }
52
53 export let closeLoginDialog: undefined | (() => void)
54 let lastPromise: Promise<any>
55 export async function loginDialog(closable=true, reloadAfter=true) {
56 return lastPromise = new Promise(resolve => {
57 if (fallbackToBasicAuth())
58 return location.href = '/?get=login'
59 if (closeLoginDialog)
60 return lastPromise // this refers to the previous promise, as lastPromise wille be updated only after this function ends
61 let going = false
62 const { close } = newDialog({
63 closable,
64 className: 'login-dialog',
65 icon: () => hIcon('login'),
66 onClose(v) {
67 resolve(v)
68 closeLoginDialog = undefined
69 },
70 title: () => h(Fragment, {}, useI18N().t(`Login`)), // this dialog could be displayed before the language has been loaded
71 Content() {
72 const usrRef = useRef<HTMLInputElement>()
73 const pwdRef = useRef<HTMLInputElement>()
74 const ipRef = useRef<HTMLInputElement>()
75 const [showPassword, setShowPassword] = useState(false)
76 const {t} = useI18N() // this dialog can be displayed before anything else, accessing protected folder, and needs to be rendered after languages loading
77 return h('form', {
78 onSubmit(ev:any) {
79 ev.preventDefault()
80 go(ev)
81 }
82 },
83 h(CustomCode, { name: 'beforeLogin' }),
84 h(CustomCode, { name: 'loginUsernameField' }, h('div', { className: 'field' },
85 h('label', { htmlFor: 'login_username' }, t`Username`),
86 h('input', {
87 ref: usrRef,
88 id: 'login_username',
89 name: 'username',
90 autoComplete: 'username',
91 autoFocus: true,
92 required: true,
93 onKeyDown
94 }),
95 )),
96 h(CustomCode, { name: 'loginPasswordField' }, h('div', { className: 'field' },
97 h('label', { htmlFor: 'login_password' }, t`Password`),
98 h('div', { className: 'password-field' },
99 h('input', {
100 ref: pwdRef,
101 id: 'login_password',
102 name: 'password',
103 type: showPassword ? 'text' : 'password',
104 autoComplete: 'current-password',
105 required: true,
106 onKeyDown
107 }),
108 h('button', {
109 type: 'button',
110 className: 'password-eye',
111 'aria-hidden': true,
112 tabIndex: -1,
113 onPointerDown(ev: PointerEvent) {
114 ev.preventDefault()
115 setShowPassword(true)
116 },
117 onPointerUp: () => setShowPassword(false),
118 onPointerEnter: () => setShowPassword(true),
119 onPointerLeave: () => setShowPassword(false),
120 onPointerCancel: () => setShowPassword(false),
121 }, hIcon('eye')),
122 ),
123 )),
124 h(CustomCode, { name: 'beforeLoginSubmit' }),
125 h('div', { className: 'submit' },
126 h('button', { type: 'submit' }, t`Continue`)),
127 h('div', { id: 'login-options' },
128 h(Checkbox, { ref: ipRef, id: ALLOW_SESSION_IP_CHANGE },
129 t(ALLOW_SESSION_IP_CHANGE, "Allow IP change during this session")),
130 ),
131 )
132
133 function onKeyDown(ev: KeyboardEvent) {
134 const { key } = ev
135 if (key === 'Escape')
136 return close(null)
137 if (key === 'Enter')
138 return go(ev)
139 }
140
141 async function go(ev: Event) {
142 const form = ev.target instanceof HTMLElement && ev.target.closest('form')
143 if (!form) return
144 ev.stopPropagation()
145 const { username, password, ...rest } = _.pickBy(Object.fromEntries(new FormData(form).entries()), _.isString) // skip files
146 const u = username.trim()
147 if (going || !u) return
148 going = true
149 try {
150 const res = await login(u, password, {
151 [ALLOW_SESSION_IP_CHANGE]: ipRef.current?.checked,
152 ...rest
153 }).finally(() => going = false)
154 await close(true)
155 toast(t`Logged in`, 'success')
156 if (res?.redirect)
157 getHFS().navigate(res.redirect)
158 else if (reloadAfter)
159 reloadList()
160 } catch (err: any) {
161 await alertDialog(err)
162 usrRef.current?.focus()
163 }
164 }
165
166 }
167 })
168 closeLoginDialog = close
169 })
170 }
171
172 export function useAuthorized() {
173 const { loginRequired, username } = useSnapState()
174 const last = useRef('')
175 useEffect(() => {
176 if (last.current === username) return // need to remember because we are not undoing our useEffect
177 last.current = username
178 if (username && getHFS().session?.requireChangePassword)
179 changePassword(true)
180 }, [username])
181 useEffect(() => {
182 if (!loginRequired)
183 closeLoginDialog?.()
184 else if (!closeLoginDialog)
185 void loginDialog(false)
186 }, [loginRequired])
187 return loginRequired ? null : true
188 }