2024-09-29 13:56:38 +02:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
customElement,
|
|
|
|
|
DeesElement,
|
|
|
|
|
property,
|
|
|
|
|
html,
|
|
|
|
|
type TemplateResult,
|
|
|
|
|
css,
|
|
|
|
|
cssManager,
|
|
|
|
|
state,
|
|
|
|
|
domtools,
|
|
|
|
|
} from '@design.estate/dees-element';
|
|
|
|
|
|
2024-09-29 16:48:06 +02:00
|
|
|
import '@uptime.link/webwidget';
|
2024-09-29 13:56:38 +02:00
|
|
|
|
2024-10-06 23:56:03 +02:00
|
|
|
import { IdpState } from '../states/idp.state.js';
|
2024-09-29 13:56:38 +02:00
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface HTMLElementTagNameMap {
|
2024-10-04 15:43:36 +02:00
|
|
|
'idp-loginprompt': IdpLoginPrompt;
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-04 15:43:36 +02:00
|
|
|
@customElement('idp-loginprompt')
|
|
|
|
|
export class IdpLoginPrompt extends DeesElement {
|
|
|
|
|
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
2024-09-29 13:56:38 +02:00
|
|
|
|
2026-04-20 09:46:13 +00:00
|
|
|
@state()
|
|
|
|
|
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
accessor oidcConsentError = '';
|
|
|
|
|
|
2026-05-19 06:20:38 +00:00
|
|
|
@state()
|
|
|
|
|
accessor mfaChallengeToken = '';
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
accessor availableMfaMethods: string[] = [];
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
accessor mfaMethod: 'totp' | 'backupCode' = 'totp';
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
accessor passkeyMessage = '';
|
|
|
|
|
|
2024-09-29 13:56:38 +02:00
|
|
|
@property()
|
2025-11-30 22:13:45 +00:00
|
|
|
accessor productOfInterest: string;
|
2024-09-29 13:56:38 +02:00
|
|
|
|
|
|
|
|
@property()
|
2025-11-30 22:13:45 +00:00
|
|
|
accessor jwt: string;
|
2024-09-29 13:56:38 +02:00
|
|
|
|
|
|
|
|
@property({
|
|
|
|
|
reflect: true,
|
2024-09-29 16:48:06 +02:00
|
|
|
type: Object,
|
2024-09-29 13:56:38 +02:00
|
|
|
})
|
2025-11-30 22:13:45 +00:00
|
|
|
accessor appData: plugins.idpInterfaces.data.IApp;
|
2024-09-29 13:56:38 +02:00
|
|
|
|
|
|
|
|
public jwtObserable = new domtools.plugins.smartrx.rxjs.Subject<string>();
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
|
|
|
|
domtools.elementBasic.setup();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:46:13 +00:00
|
|
|
private getOidcAuthorizationContext(): Omit<
|
|
|
|
|
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
|
|
|
|
'jwt'
|
|
|
|
|
> | null {
|
|
|
|
|
const currentUrl = plugins.smarturl.Smarturl.createFromUrl(window.location.href);
|
|
|
|
|
|
|
|
|
|
if (currentUrl.searchParams.oauth !== 'true') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const clientId = currentUrl.searchParams.client_id;
|
|
|
|
|
const redirectUri = currentUrl.searchParams.redirect_uri;
|
|
|
|
|
const scope = currentUrl.searchParams.scope;
|
|
|
|
|
const state = currentUrl.searchParams.state;
|
|
|
|
|
|
|
|
|
|
if (!clientId || !redirectUri || !scope || !state) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prompt = ['none', 'login', 'consent'].includes(currentUrl.searchParams.prompt)
|
|
|
|
|
? (currentUrl.searchParams.prompt as 'none' | 'login' | 'consent')
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
clientId,
|
|
|
|
|
redirectUri,
|
|
|
|
|
scope,
|
|
|
|
|
state,
|
|
|
|
|
prompt,
|
|
|
|
|
codeChallenge: currentUrl.searchParams.code_challenge || undefined,
|
|
|
|
|
codeChallengeMethod:
|
|
|
|
|
currentUrl.searchParams.code_challenge_method === 'S256' ? 'S256' : undefined,
|
|
|
|
|
nonce: currentUrl.searchParams.nonce || undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private redirectOidcError(errorArg: string, descriptionArg?: string) {
|
|
|
|
|
const oidcContext = this.getOidcAuthorizationContext();
|
|
|
|
|
if (!oidcContext) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const redirectUrl = new URL(oidcContext.redirectUri);
|
|
|
|
|
redirectUrl.searchParams.set('error', errorArg);
|
|
|
|
|
redirectUrl.searchParams.set('state', oidcContext.state);
|
|
|
|
|
if (descriptionArg) {
|
|
|
|
|
redirectUrl.searchParams.set('error_description', descriptionArg);
|
|
|
|
|
}
|
|
|
|
|
window.location.href = redirectUrl.toString();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getOidcScopeDescription(scopeArg: plugins.idpInterfaces.data.TOidcScope) {
|
|
|
|
|
const scopeMap: Record<plugins.idpInterfaces.data.TOidcScope, string> = {
|
|
|
|
|
openid: 'Confirm your identity with this app.',
|
|
|
|
|
profile: 'Share your display name and username.',
|
|
|
|
|
email: 'Share your email address.',
|
|
|
|
|
organizations: 'Share your organizations and their roles.',
|
|
|
|
|
roles: 'Share your platform roles.',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return scopeMap[scopeArg];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getOidcAppHost(appUrlArg: string) {
|
|
|
|
|
try {
|
|
|
|
|
return new URL(appUrlArg).hostname;
|
|
|
|
|
} catch {
|
|
|
|
|
return appUrlArg;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async prepareOidcAuthorization(jwtArg: string) {
|
|
|
|
|
const oidcContext = this.getOidcAuthorizationContext();
|
|
|
|
|
if (!oidcContext) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
return idpState.idpClient.requests.prepareOidcAuthorization
|
|
|
|
|
.fire({
|
|
|
|
|
jwt: jwtArg,
|
|
|
|
|
...oidcContext,
|
|
|
|
|
})
|
|
|
|
|
.catch(() => null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleOidcAfterLogin(jwtArg: string) {
|
|
|
|
|
const oidcContext = this.getOidcAuthorizationContext();
|
|
|
|
|
if (!oidcContext) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 15:35:37 +00:00
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
2026-04-20 09:46:13 +00:00
|
|
|
loginForm?.setStatus('pending', 'preparing application authorization...');
|
|
|
|
|
this.oidcConsentError = '';
|
|
|
|
|
|
|
|
|
|
const preparation = await this.prepareOidcAuthorization(jwtArg);
|
|
|
|
|
if (!preparation) {
|
|
|
|
|
loginForm?.setStatus('error', 'could not prepare the application authorization');
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preparation.status === 'consent_required') {
|
|
|
|
|
if (oidcContext.prompt === 'none') {
|
|
|
|
|
this.redirectOidcError('consent_required');
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.oidcConsentState = preparation;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.completeOidcAuthorization(jwtArg);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async completeOidcAuthorization(jwtArg: string, consentApproved = false) {
|
|
|
|
|
const oidcContext = this.getOidcAuthorizationContext();
|
|
|
|
|
if (!oidcContext) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
2026-05-07 15:35:37 +00:00
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
2026-04-20 09:46:13 +00:00
|
|
|
loginForm?.setStatus('pending', 'authorizing application...');
|
|
|
|
|
this.oidcConsentError = '';
|
|
|
|
|
|
|
|
|
|
const response = await idpState.idpClient.requests.completeOidcAuthorization
|
|
|
|
|
.fire({
|
|
|
|
|
jwt: jwtArg,
|
|
|
|
|
...oidcContext,
|
|
|
|
|
consentApproved,
|
|
|
|
|
})
|
|
|
|
|
.catch(() => null);
|
|
|
|
|
|
|
|
|
|
if (!response?.redirectUrl) {
|
|
|
|
|
if (this.oidcConsentState) {
|
|
|
|
|
this.oidcConsentError = 'Could not authorize the application.';
|
|
|
|
|
} else {
|
|
|
|
|
loginForm?.setStatus('error', 'could not authorize the application');
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.location.href = response.redirectUrl;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 06:20:38 +00:00
|
|
|
private async completeLoginWithRefreshToken(refreshTokenArg: string) {
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
|
|
|
|
loginForm?.setStatus('pending', 'obtained refreshToken...');
|
|
|
|
|
const jwt = await idpState.idpClient.refreshJwt(refreshTokenArg);
|
|
|
|
|
if (!jwt) {
|
|
|
|
|
loginForm?.setStatus('error', 'something went wrong');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
loginForm?.setStatus('success', 'obtained jwt.');
|
|
|
|
|
const oidcHandled = await this.handleOidcAfterLogin(jwt);
|
|
|
|
|
if (!oidcHandled) {
|
|
|
|
|
idpState.domtools.router.pushUrl('/dash/overview');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-29 13:56:38 +02:00
|
|
|
public static styles = [
|
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
|
css`
|
|
|
|
|
:host {
|
2025-11-30 22:35:24 +00:00
|
|
|
--foreground: hsl(0 0% 98%);
|
|
|
|
|
--muted-foreground: hsl(240 5% 64.9%);
|
|
|
|
|
|
|
|
|
|
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
2024-09-29 13:56:38 +02:00
|
|
|
display: block;
|
2025-11-30 22:35:24 +00:00
|
|
|
color: var(--foreground);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.form-header {
|
|
|
|
|
margin-bottom: 32px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.form-header h2 {
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--foreground);
|
|
|
|
|
margin: 0 0 8px 0;
|
|
|
|
|
letter-spacing: -0.02em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.form-header p {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: var(--muted-foreground);
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 15:35:37 +00:00
|
|
|
idp-form {
|
2025-11-30 22:35:24 +00:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 16px;
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 22:35:24 +00:00
|
|
|
.form-footer {
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: var(--muted-foreground);
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 22:35:24 +00:00
|
|
|
.form-footer a {
|
|
|
|
|
color: var(--foreground);
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: opacity 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.form-footer a:hover {
|
|
|
|
|
opacity: 0.8;
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|
2026-04-20 09:46:13 +00:00
|
|
|
|
|
|
|
|
.consent-card {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 24px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-appname {
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-appurl {
|
|
|
|
|
color: var(--muted-foreground);
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-scopes {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-scope {
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-scope-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-scope-tag {
|
|
|
|
|
color: #9cd67c;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-scope-description {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
color: var(--muted-foreground);
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.consent-error {
|
|
|
|
|
color: #ff9a9a;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
2024-09-29 13:56:38 +02:00
|
|
|
`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public render(): TemplateResult {
|
2026-05-19 06:20:38 +00:00
|
|
|
if (this.mfaChallengeToken) {
|
|
|
|
|
const passkeyAvailable = this.availableMfaMethods.includes('passkey');
|
|
|
|
|
const backupAvailable = this.availableMfaMethods.includes('backupCode');
|
|
|
|
|
return html`
|
|
|
|
|
<idp-centercontainer>
|
|
|
|
|
<div class="form-header">
|
|
|
|
|
<h2>Verify your sign-in</h2>
|
|
|
|
|
<p>Enter your authenticator code${passkeyAvailable ? ' or approve with a passkey' : ''}.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<idp-form
|
|
|
|
|
id="mfaForm"
|
|
|
|
|
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
|
|
|
|
|
this.verifyMfaCode(String(eventArg.detail.data.mfaCode || ''));
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<idp-input
|
|
|
|
|
id="mfaCodeInput"
|
|
|
|
|
required
|
|
|
|
|
name="mfaCode"
|
|
|
|
|
label=${this.mfaMethod === 'backupCode' ? 'Backup code' : 'Authenticator code'}
|
|
|
|
|
autocomplete="one-time-code"
|
|
|
|
|
></idp-input>
|
|
|
|
|
<idp-form-submit id="mfaSubmitButton"></idp-form-submit>
|
|
|
|
|
</idp-form>
|
|
|
|
|
<div class="form-footer">
|
|
|
|
|
${backupAvailable ? html`
|
|
|
|
|
<a @click=${() => {
|
|
|
|
|
this.mfaMethod = this.mfaMethod === 'backupCode' ? 'totp' : 'backupCode';
|
|
|
|
|
}}>${this.mfaMethod === 'backupCode' ? 'Use authenticator code' : 'Use backup code'}</a>
|
|
|
|
|
` : null}
|
|
|
|
|
${passkeyAvailable ? html`
|
|
|
|
|
${backupAvailable ? html` · ` : null}
|
|
|
|
|
<a @click=${() => this.verifyMfaWithPasskey()}>Use passkey</a>
|
|
|
|
|
` : null}
|
|
|
|
|
</div>
|
|
|
|
|
${this.passkeyMessage ? html`<div class="form-footer">${this.passkeyMessage}</div>` : null}
|
|
|
|
|
</idp-centercontainer>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:46:13 +00:00
|
|
|
if (this.oidcConsentState) {
|
|
|
|
|
return html`
|
|
|
|
|
<idp-centercontainer>
|
|
|
|
|
<div class="form-header">
|
|
|
|
|
<h2>Continue to ${this.oidcConsentState.appName}</h2>
|
|
|
|
|
<p>Review and approve the access this app is requesting.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="consent-card">
|
|
|
|
|
<div class="consent-appname">${this.oidcConsentState.appName}</div>
|
|
|
|
|
<div class="consent-appurl">${this.getOidcAppHost(this.oidcConsentState.appUrl)}</div>
|
|
|
|
|
<div class="consent-scopes">
|
|
|
|
|
${this.oidcConsentState.requestedScopes.map((scopeArg) => html`
|
|
|
|
|
<div class="consent-scope">
|
|
|
|
|
<div class="consent-scope-header">
|
|
|
|
|
<span>${scopeArg}</span>
|
|
|
|
|
${this.oidcConsentState.grantedScopes.includes(scopeArg)
|
|
|
|
|
? html`<span class="consent-scope-tag">Previously allowed</span>`
|
|
|
|
|
: null}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="consent-scope-description">${this.getOidcScopeDescription(scopeArg)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`)}
|
|
|
|
|
</div>
|
|
|
|
|
${this.oidcConsentError ? html`<div class="consent-error">${this.oidcConsentError}</div>` : null}
|
|
|
|
|
<div class="consent-actions">
|
2026-05-07 15:35:37 +00:00
|
|
|
<idp-button
|
|
|
|
|
variant="outline"
|
2026-04-20 09:46:13 +00:00
|
|
|
@click=${() => {
|
|
|
|
|
this.redirectOidcError('access_denied');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
2026-05-07 15:35:37 +00:00
|
|
|
</idp-button>
|
|
|
|
|
<idp-button
|
|
|
|
|
variant="accent"
|
2026-04-20 09:46:13 +00:00
|
|
|
@click=${async () => {
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
const jwt = await idpState.idpClient.getJwt();
|
|
|
|
|
if (!jwt) {
|
|
|
|
|
this.redirectOidcError('login_required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await this.completeOidcAuthorization(jwt, true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Allow and continue
|
2026-05-07 15:35:37 +00:00
|
|
|
</idp-button>
|
2026-04-20 09:46:13 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</idp-centercontainer>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-29 13:56:38 +02:00
|
|
|
return html`
|
2024-10-04 15:43:36 +02:00
|
|
|
<idp-centercontainer>
|
2025-11-30 22:35:24 +00:00
|
|
|
<div class="form-header">
|
|
|
|
|
<h2>Sign in to your account</h2>
|
|
|
|
|
<p>Enter your credentials to continue</p>
|
|
|
|
|
</div>
|
2026-05-07 15:35:37 +00:00
|
|
|
<idp-form
|
2025-11-30 22:35:24 +00:00
|
|
|
id="loginForm"
|
2026-05-07 15:35:37 +00:00
|
|
|
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
|
2025-11-30 22:35:24 +00:00
|
|
|
this.login({
|
2026-05-07 15:35:37 +00:00
|
|
|
emailAddress: String(eventArg.detail.data.emailAddress || ''),
|
|
|
|
|
passwordArg: String(eventArg.detail.data.password || ''),
|
2025-11-30 22:35:24 +00:00
|
|
|
});
|
2026-04-20 09:46:13 +00:00
|
|
|
}}
|
2025-11-30 22:35:24 +00:00
|
|
|
>
|
2026-05-07 15:35:37 +00:00
|
|
|
<idp-input
|
2025-11-30 22:35:24 +00:00
|
|
|
id="loginEmailInput"
|
2026-05-07 15:35:37 +00:00
|
|
|
required
|
|
|
|
|
name="emailAddress"
|
2025-11-30 22:35:24 +00:00
|
|
|
label="Email or Username"
|
2026-05-19 06:20:38 +00:00
|
|
|
autocomplete="username webauthn"
|
2026-05-07 15:35:37 +00:00
|
|
|
></idp-input>
|
|
|
|
|
<idp-input
|
|
|
|
|
id="loginPasswordInput"
|
|
|
|
|
name="password"
|
|
|
|
|
label="Password"
|
|
|
|
|
type="password"
|
|
|
|
|
autocomplete="current-password"
|
|
|
|
|
></idp-input>
|
|
|
|
|
<idp-form-submit id="loginSubmitButton"></idp-form-submit>
|
|
|
|
|
</idp-form>
|
2025-11-30 22:35:24 +00:00
|
|
|
<div class="form-footer">
|
2026-05-19 06:20:38 +00:00
|
|
|
<a @click=${() => this.loginWithPasskey()}>Sign in with passkey</a>
|
|
|
|
|
<br />
|
2026-04-20 09:46:13 +00:00
|
|
|
Don't have an account?
|
|
|
|
|
<a @click=${async () => {
|
2024-10-04 15:43:36 +02:00
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
idpState.domtools.router.pushUrl('/register');
|
2025-11-30 22:35:24 +00:00
|
|
|
}}>Create one</a>
|
2024-10-04 15:43:36 +02:00
|
|
|
</div>
|
|
|
|
|
</idp-centercontainer>
|
2024-09-29 13:56:38 +02:00
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async firstUpdated() {
|
2026-04-20 09:46:13 +00:00
|
|
|
await this.domtoolsPromise;
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
2026-05-07 15:35:37 +00:00
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
|
|
|
|
|
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as plugins.idpCatalog.IdpInput;
|
|
|
|
|
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as plugins.idpCatalog.IdpFormSubmit;
|
2026-04-20 09:46:13 +00:00
|
|
|
const oidcContext = this.getOidcAuthorizationContext();
|
2024-09-29 13:56:38 +02:00
|
|
|
const setButtonText = async () => {
|
|
|
|
|
if (loginPasswordInput.value) {
|
2026-04-20 09:46:13 +00:00
|
|
|
loginSubmitButton.text = oidcContext ? 'Sign in and continue' : 'Login';
|
2024-09-29 13:56:38 +02:00
|
|
|
} else {
|
|
|
|
|
loginSubmitButton.text = 'Send magic link (or enter password)';
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-05-07 15:35:37 +00:00
|
|
|
loginForm.addEventListener('idp-input-change', () => {
|
2026-04-20 09:46:13 +00:00
|
|
|
void setButtonText();
|
2024-09-29 13:56:38 +02:00
|
|
|
});
|
2026-04-20 09:46:13 +00:00
|
|
|
await setButtonText();
|
|
|
|
|
|
|
|
|
|
if (oidcContext) {
|
|
|
|
|
const loggedIn = await idpState.idpClient.determineLoginStatus(false);
|
|
|
|
|
if (!loggedIn && oidcContext.prompt === 'none') {
|
|
|
|
|
this.redirectOidcError('login_required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loggedIn && oidcContext.prompt !== 'login') {
|
|
|
|
|
const jwt = await idpState.idpClient.getJwt();
|
|
|
|
|
if (jwt) {
|
|
|
|
|
await this.handleOidcAfterLogin(jwt);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-07 15:35:37 +00:00
|
|
|
} else if (await idpState.idpClient.determineLoginStatus(false)) {
|
|
|
|
|
idpState.domtools.router.pushUrl('/dash/overview');
|
2026-04-20 09:46:13 +00:00
|
|
|
}
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
2026-04-20 09:46:13 +00:00
|
|
|
const loginSubmitButton = this.shadowRoot.querySelector(
|
|
|
|
|
'#loginSubmitButton'
|
2026-05-07 15:35:37 +00:00
|
|
|
) as plugins.idpCatalog.IdpFormSubmit;
|
2025-12-01 04:08:17 +00:00
|
|
|
loginSubmitButton.disabled = true;
|
2026-04-20 09:46:13 +00:00
|
|
|
|
2024-10-06 23:56:03 +02:00
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
2026-05-07 15:35:37 +00:00
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
|
2024-09-29 13:56:38 +02:00
|
|
|
const loginRequestWithUsernameAndPassword =
|
2025-12-04 17:45:40 +00:00
|
|
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
2024-09-29 13:56:38 +02:00
|
|
|
'loginWithEmailOrUsernameAndPassword'
|
|
|
|
|
);
|
|
|
|
|
const loginRequestWithEmail =
|
2025-12-04 17:45:40 +00:00
|
|
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
2024-09-29 13:56:38 +02:00
|
|
|
'loginWithEmail'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (valueArg.emailAddress && valueArg.passwordArg) {
|
|
|
|
|
loginForm.setStatus('pending', 'logging in...');
|
|
|
|
|
const response = await loginRequestWithUsernameAndPassword
|
|
|
|
|
.fire({
|
2026-04-20 09:46:13 +00:00
|
|
|
username: valueArg.emailAddress,
|
2024-09-29 13:56:38 +02:00
|
|
|
password: valueArg.passwordArg,
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
2026-04-20 09:46:13 +00:00
|
|
|
return null;
|
2024-09-29 13:56:38 +02:00
|
|
|
});
|
|
|
|
|
if (!response) {
|
2026-04-20 09:46:13 +00:00
|
|
|
loginSubmitButton.disabled = false;
|
2024-09-29 13:56:38 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (response.refreshToken) {
|
2026-05-19 06:20:38 +00:00
|
|
|
await this.completeLoginWithRefreshToken(response.refreshToken);
|
|
|
|
|
} else if (response.twoFaNeeded && response.mfaChallengeToken) {
|
|
|
|
|
this.mfaChallengeToken = response.mfaChallengeToken;
|
|
|
|
|
this.availableMfaMethods = response.availableMfaMethods || ['totp'];
|
|
|
|
|
this.mfaMethod = this.availableMfaMethods.includes('totp') ? 'totp' : 'backupCode';
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|
|
|
|
|
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
|
|
|
|
loginForm.setStatus('pending', 'sending magic link...');
|
|
|
|
|
const response = await loginRequestWithEmail.fire({
|
|
|
|
|
email: valueArg.emailAddress,
|
2026-05-07 15:35:37 +00:00
|
|
|
}).catch((err) => {
|
|
|
|
|
const message = err?.errorText || err?.message || 'Could not send the magic link. Please try again.';
|
|
|
|
|
loginForm.setStatus('error', message);
|
|
|
|
|
return null;
|
2024-09-29 13:56:38 +02:00
|
|
|
});
|
2026-05-07 15:35:37 +00:00
|
|
|
if (response?.status === 'ok') {
|
2024-09-29 13:56:38 +02:00
|
|
|
loginForm.setStatus('success', 'Please check your email!');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-20 09:46:13 +00:00
|
|
|
|
|
|
|
|
loginSubmitButton.disabled = false;
|
2024-09-29 13:56:38 +02:00
|
|
|
};
|
|
|
|
|
|
2026-05-19 06:20:38 +00:00
|
|
|
private verifyMfaCode = async (codeArg: string) => {
|
|
|
|
|
const mfaForm = this.shadowRoot.querySelector('#mfaForm') as plugins.idpCatalog.IdpForm;
|
|
|
|
|
const submitButton = this.shadowRoot.querySelector('#mfaSubmitButton') as plugins.idpCatalog.IdpFormSubmit;
|
|
|
|
|
submitButton.disabled = true;
|
|
|
|
|
mfaForm.setStatus('pending', 'verifying...');
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('verifyMfaChallenge');
|
|
|
|
|
const response = await request.fire({
|
|
|
|
|
mfaChallengeToken: this.mfaChallengeToken,
|
|
|
|
|
method: this.mfaMethod,
|
|
|
|
|
code: codeArg,
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
mfaForm.setStatus('error', 'invalid verification code');
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
if (response?.refreshToken) {
|
|
|
|
|
await this.completeLoginWithRefreshToken(response.refreshToken);
|
|
|
|
|
}
|
|
|
|
|
submitButton.disabled = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private verifyMfaWithPasskey = async () => {
|
|
|
|
|
this.passkeyMessage = 'Waiting for passkey approval...';
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyMfa');
|
|
|
|
|
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyMfa');
|
|
|
|
|
const startResponse = await startRequest.fire({
|
|
|
|
|
mfaChallengeToken: this.mfaChallengeToken,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!startResponse?.options) {
|
|
|
|
|
this.passkeyMessage = 'Could not start passkey verification.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const assertion = await plugins.simpleWebAuthnBrowser.startAuthentication({
|
|
|
|
|
optionsJSON: startResponse.options,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!assertion) {
|
|
|
|
|
this.passkeyMessage = 'Passkey verification was cancelled.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const finishResponse = await finishRequest.fire({
|
|
|
|
|
mfaChallengeToken: this.mfaChallengeToken,
|
|
|
|
|
challengeId: startResponse.challengeId,
|
|
|
|
|
response: assertion,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!finishResponse?.refreshToken) {
|
|
|
|
|
this.passkeyMessage = 'Passkey verification failed.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await this.completeLoginWithRefreshToken(finishResponse.refreshToken);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private loginWithPasskey = async () => {
|
|
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
|
|
|
|
|
const emailInput = this.shadowRoot.querySelector('#loginEmailInput') as plugins.idpCatalog.IdpInput;
|
|
|
|
|
loginForm.setStatus('pending', 'starting passkey sign-in...');
|
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
|
|
|
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyLogin');
|
|
|
|
|
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyLogin');
|
|
|
|
|
const startResponse = await startRequest.fire({
|
|
|
|
|
username: emailInput.value || undefined,
|
|
|
|
|
}).catch((errorArg) => {
|
|
|
|
|
loginForm.setStatus('error', errorArg?.message || 'could not start passkey sign-in');
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
if (!startResponse?.options) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const assertion = await plugins.simpleWebAuthnBrowser.startAuthentication({
|
|
|
|
|
optionsJSON: startResponse.options,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!assertion) {
|
|
|
|
|
loginForm.setStatus('error', 'passkey sign-in was cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const finishResponse = await finishRequest.fire({
|
|
|
|
|
challengeId: startResponse.challengeId,
|
|
|
|
|
response: assertion,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!finishResponse?.refreshToken) {
|
|
|
|
|
loginForm.setStatus('error', 'passkey sign-in failed');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await this.completeLoginWithRefreshToken(finishResponse.refreshToken);
|
|
|
|
|
};
|
|
|
|
|
|
2024-09-29 13:56:38 +02:00
|
|
|
public async dispatchJwt(jwtArg?: string) {
|
|
|
|
|
if (jwtArg !== undefined) {
|
|
|
|
|
this.jwt = jwtArg;
|
|
|
|
|
await domtools.plugins.smartdelay.delayFor(200);
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
|
new CustomEvent('leleLoginGotJwt', {
|
|
|
|
|
detail: {
|
|
|
|
|
jwt: this.jwt,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
this.jwtObserable.next(this.jwt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-04 02:18:47 +02:00
|
|
|
public async focus() {
|
2026-05-07 15:35:37 +00:00
|
|
|
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.idpCatalog.IdpInput).focus();
|
2024-10-04 15:43:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async show() {
|
|
|
|
|
await this.updateComplete;
|
|
|
|
|
const centerContainer = this.shadowRoot.querySelector('idp-centercontainer');
|
|
|
|
|
await centerContainer.show();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async hide() {
|
|
|
|
|
await this.updateComplete;
|
|
|
|
|
const centerContainer = this.shadowRoot.querySelector('idp-centercontainer');
|
|
|
|
|
await centerContainer.hide();
|
2024-10-04 02:18:47 +02:00
|
|
|
}
|
2024-09-29 13:56:38 +02:00
|
|
|
}
|