auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

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 };