login.tsx (10642B)
1 import m from 'mithril'; 2 import { 3 getClientDiscovery, 4 getSession, 5 resolveLogin, 6 selectSession, 7 signInPassword, 8 type ClientDiscovery, 9 type LoginResolution, 10 type PasswordLoginResult, 11 type SessionAccount, 12 } from '../util/api'; 13 14 type Step = 'accounts' | 'identifier' | 'password' | 'oidc' | 'done'; 15 16 interface TextFieldAttrs { 17 id: string; 18 label: string; 19 type?: string; 20 value: string; 21 autocomplete?: string; 22 autofocus?: boolean; 23 disabled?: boolean; 24 oninput: (value: string) => void; 25 } 26 27 const TextField: m.Component<TextFieldAttrs> = { 28 view({ attrs }) { 29 return ( 30 <div class="auth-field"> 31 <input 32 class="auth-input" 33 id={attrs.id} 34 type={attrs.type || 'text'} 35 value={attrs.value} 36 placeholder=" " 37 autocomplete={attrs.autocomplete} 38 disabled={attrs.disabled} 39 oninput={(e: Event) => attrs.oninput((e.target as HTMLInputElement).value)} 40 oncreate={attrs.autofocus ? ({ dom }: any) => dom.focus() : undefined} 41 /> 42 <label for={attrs.id}>{attrs.label}</label> 43 </div> 44 ); 45 }, 46 }; 47 48 export const Login: m.Component<{ realm?: string; clientId?: string }> = { 49 realmSlug: null as string | null, 50 clientId: null as string | null, 51 discovery: null as ClientDiscovery | null, 52 step: 'identifier' as Step, 53 accounts: [] as SessionAccount[], 54 identifier: '', 55 password: '', 56 longSession: false, 57 provider: null as LoginResolution['provider'] | null, 58 signedIn: null as PasswordLoginResult['user'] | null, 59 error: null as Error | null, 60 pending: false, 61 62 oninit(this: any, vnode: any) { 63 this.realmSlug = vnode.attrs.realm ?? null; 64 this.clientId = vnode.attrs.clientId ?? null; 65 this.loadClient(); 66 }, 67 68 async loadClient(this: any) { 69 this.error = null; 70 try { 71 const discovery = await getClientDiscovery(this.clientId); 72 this.discovery = discovery; 73 this.realmSlug = discovery.realm; 74 this.clientId = discovery.client_id; 75 document.title = discovery.display_name; 76 await this.loadAccounts(); 77 } catch (e) { 78 this.error = e as Error; 79 } 80 m.redraw(); 81 }, 82 83 async loadAccounts(this: any) { 84 try { 85 const session = await getSession(); 86 if (session.accounts.length >= 2) { 87 this.accounts = session.accounts; 88 this.step = 'accounts'; 89 } 90 } catch { 91 this.accounts = []; 92 } 93 }, 94 95 async submitIdentifier(this: any) { 96 this.pending = true; 97 this.error = null; 98 try { 99 const result = await resolveLogin(this.clientId, this.identifier); 100 if (result.next === 'oidc' && result.provider) { 101 this.provider = result.provider; 102 this.step = 'oidc'; 103 } else { 104 this.step = 'password'; 105 } 106 } catch (e) { 107 this.error = e as Error; 108 } 109 this.pending = false; 110 m.redraw(); 111 }, 112 113 async submitPassword(this: any) { 114 this.pending = true; 115 this.error = null; 116 try { 117 const result = await signInPassword( 118 this.clientId, 119 this.identifier, 120 this.password, 121 this.longSession 122 ); 123 124 if (result.pending_auth_request) { 125 const current = m.route.get() || ''; 126 m.route.set(current.replace(/\/login$/, '/authorize')); 127 return; 128 } 129 130 if (this.clientId === 'admin') { 131 m.route.set(this.realmSlug ? `/realms/${this.realmSlug}/admin` : '/admin'); 132 return; 133 } 134 135 this.signedIn = result.user; 136 this.step = 'done'; 137 this.pending = false; 138 m.redraw(); 139 } catch (e) { 140 this.error = e as Error; 141 this.pending = false; 142 m.redraw(); 143 } 144 }, 145 146 async selectAccount(this: any, account: SessionAccount) { 147 this.pending = true; 148 this.error = null; 149 try { 150 const result = await selectSession(account.public_id, this.clientId); 151 if (result.pending_auth_request) { 152 const current = m.route.get() || ''; 153 m.route.set(current.replace(/\/login$/, '/authorize')); 154 return; 155 } 156 157 if (this.clientId === 'admin') { 158 m.route.set(this.realmSlug ? `/realms/${this.realmSlug}/admin` : '/admin'); 159 return; 160 } 161 162 this.signedIn = result.user; 163 this.step = 'done'; 164 } catch (e) { 165 this.error = e as Error; 166 } 167 this.pending = false; 168 m.redraw(); 169 }, 170 171 differentAccount(this: any) { 172 this.step = 'identifier'; 173 this.identifier = ''; 174 this.password = ''; 175 this.longSession = false; 176 this.provider = null; 177 this.error = null; 178 m.redraw(); 179 }, 180 181 back(this: any) { 182 this.step = 'identifier'; 183 this.password = ''; 184 this.longSession = false; 185 this.provider = null; 186 this.error = null; 187 m.redraw(); 188 }, 189 190 backToAccounts(this: any) { 191 this.step = 'accounts'; 192 this.error = null; 193 m.redraw(); 194 }, 195 196 view(this: any) { 197 const branding = this.discovery?.branding || {}; 198 const primary = typeof branding.primary_color === 'string' ? branding.primary_color : '#25A'; 199 const style = { '--realm-primary': primary } as any; 200 201 if (!this.discovery && this.error) { 202 return ( 203 <div class="auth-page" style={style}> 204 <div class="auth-card"> 205 <h1>Sign in</h1> 206 <p class="auth-error">{this.error.message}</p> 207 </div> 208 </div> 209 ); 210 } 211 212 const title = this.discovery?.display_name || 'Sign in'; 213 214 return ( 215 <div class="auth-page" style={style}> 216 <div class="auth-card"> 217 {typeof branding.logo_url === 'string' && branding.logo_url ? ( 218 <img class="auth-logo" src={branding.logo_url} alt={title} /> 219 ) : null} 220 <h1>{title}</h1> 221 222 {this.step === 'accounts' ? this.renderAccounts() : null} 223 {this.step === 'identifier' ? this.renderIdentifier() : null} 224 {this.step === 'password' ? this.renderPassword() : null} 225 {this.step === 'oidc' ? this.renderProvider() : null} 226 {this.step === 'done' ? this.renderDone() : null} 227 </div> 228 </div> 229 ); 230 }, 231 232 renderAccounts(this: any) { 233 return ( 234 <div> 235 <p class="auth-note">Choose an account to continue.</p> 236 <div class="auth-accounts"> 237 {this.accounts.map((account: SessionAccount) => { 238 const name = account.user.display_name || account.user.username; 239 const secondary = 240 account.user.display_name && account.user.display_name !== account.user.username 241 ? account.user.username 242 : null; 243 return ( 244 <button 245 class="auth-account" 246 type="button" 247 disabled={this.pending} 248 onclick={() => this.selectAccount(account)} 249 > 250 <span class="auth-account-name">{name}</span> 251 {secondary ? <span class="auth-account-username">{secondary}</span> : null} 252 </button> 253 ); 254 })} 255 </div> 256 {this.error ? <p class="auth-error">{this.error.message}</p> : null} 257 <div class="auth-actions"> 258 <button class="auth-link" type="button" onclick={() => this.differentAccount()}> 259 Different account 260 </button> 261 </div> 262 </div> 263 ); 264 }, 265 266 renderIdentifier(this: any) { 267 return ( 268 <form 269 onsubmit={(e: Event) => { 270 e.preventDefault(); 271 if (!this.pending && this.identifier.trim()) this.submitIdentifier(); 272 }} 273 > 274 <TextField 275 id="identifier" 276 label="Email or username" 277 value={this.identifier} 278 autocomplete="username" 279 autofocus 280 disabled={this.pending} 281 oninput={(value: string) => { 282 this.identifier = value; 283 }} 284 /> 285 {this.error ? <p class="auth-error">{this.error.message}</p> : null} 286 <div class="auth-actions"> 287 {this.accounts.length >= 2 ? ( 288 <button class="auth-link" type="button" onclick={() => this.backToAccounts()}> 289 Back to accounts 290 </button> 291 ) : null} 292 <button class="auth-button" type="submit" disabled={this.pending || !this.identifier.trim()}> 293 {this.pending ? 'Checking' : 'Next'} 294 </button> 295 </div> 296 </form> 297 ); 298 }, 299 300 renderPassword(this: any) { 301 return ( 302 <form 303 onsubmit={(e: Event) => { 304 e.preventDefault(); 305 if (!this.pending) this.submitPassword(); 306 }} 307 > 308 <p class="auth-identifier">{this.identifier}</p> 309 <TextField 310 id="password" 311 label="Password" 312 type="password" 313 value={this.password} 314 autocomplete="current-password" 315 autofocus 316 disabled={this.pending} 317 oninput={(value: string) => { 318 this.password = value; 319 }} 320 /> 321 {this.discovery?.allow_session_duration_choice !== false ? ( 322 <label class="auth-choice"> 323 <input 324 type="checkbox" 325 checked={this.longSession} 326 disabled={this.pending} 327 onchange={(e: Event) => { 328 this.longSession = (e.target as HTMLInputElement).checked; 329 }} 330 /> 331 <span>Keep me signed in</span> 332 </label> 333 ) : null} 334 {this.error ? <p class="auth-error">{this.error.message}</p> : null} 335 <div class="auth-actions"> 336 <button class="auth-link" type="button" onclick={() => this.back()}> 337 Back 338 </button> 339 <button class="auth-button" type="submit" disabled={this.pending || !this.password}> 340 {this.pending ? 'Signing in' : 'Sign in'} 341 </button> 342 </div> 343 </form> 344 ); 345 }, 346 347 renderProvider(this: any) { 348 const provider = this.provider; 349 return ( 350 <div> 351 <p class="auth-identifier">{this.identifier}</p> 352 <p class="auth-note"> 353 {provider 354 ? `This account signs in with ${provider.display_name}.` 355 : 'This account signs in with an identity provider.'} 356 </p> 357 <div class="auth-actions"> 358 <button class="auth-link" type="button" onclick={() => this.back()}> 359 Back 360 </button> 361 <button class="auth-button" type="button" disabled> 362 Continue 363 </button> 364 </div> 365 </div> 366 ); 367 }, 368 369 renderDone(this: any) { 370 const user = this.signedIn; 371 return ( 372 <div> 373 <p class="auth-note">Signed in as {user?.display_name || user?.username}</p> 374 </div> 375 ); 376 }, 377 };