552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
|
|
import {
|
|
customElement,
|
|
DeesElement,
|
|
property,
|
|
html,
|
|
type TemplateResult,
|
|
css,
|
|
cssManager,
|
|
state,
|
|
domtools,
|
|
} from '@design.estate/dees-element';
|
|
|
|
import '@uptime.link/webwidget';
|
|
|
|
import { IdpState } from '../states/idp.state.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'idp-loginprompt': IdpLoginPrompt;
|
|
}
|
|
}
|
|
|
|
@customElement('idp-loginprompt')
|
|
export class IdpLoginPrompt extends DeesElement {
|
|
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
|
|
|
@state()
|
|
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
|
|
|
|
@state()
|
|
accessor oidcConsentError = '';
|
|
|
|
@property()
|
|
accessor productOfInterest: string;
|
|
|
|
@property()
|
|
accessor jwt: string;
|
|
|
|
@property({
|
|
reflect: true,
|
|
type: Object,
|
|
})
|
|
accessor appData: plugins.idpInterfaces.data.IApp;
|
|
|
|
public jwtObserable = new domtools.plugins.smartrx.rxjs.Subject<string>();
|
|
|
|
constructor() {
|
|
super();
|
|
domtools.elementBasic.setup();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
|
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();
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
|
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;
|
|
}
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
css`
|
|
:host {
|
|
--foreground: hsl(0 0% 98%);
|
|
--muted-foreground: hsl(240 5% 64.9%);
|
|
|
|
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
display: block;
|
|
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;
|
|
}
|
|
|
|
idp-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.form-footer {
|
|
margin-top: 24px;
|
|
text-align: center;
|
|
font-size: 14px;
|
|
color: var(--muted-foreground);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
`,
|
|
];
|
|
|
|
public render(): TemplateResult {
|
|
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">
|
|
<idp-button
|
|
variant="outline"
|
|
@click=${() => {
|
|
this.redirectOidcError('access_denied');
|
|
}}
|
|
>
|
|
Cancel
|
|
</idp-button>
|
|
<idp-button
|
|
variant="accent"
|
|
@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
|
|
</idp-button>
|
|
</div>
|
|
</div>
|
|
</idp-centercontainer>
|
|
`;
|
|
}
|
|
|
|
return html`
|
|
<idp-centercontainer>
|
|
<div class="form-header">
|
|
<h2>Sign in to your account</h2>
|
|
<p>Enter your credentials to continue</p>
|
|
</div>
|
|
<idp-form
|
|
id="loginForm"
|
|
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
|
|
this.login({
|
|
emailAddress: String(eventArg.detail.data.emailAddress || ''),
|
|
passwordArg: String(eventArg.detail.data.password || ''),
|
|
});
|
|
}}
|
|
>
|
|
<idp-input
|
|
id="loginEmailInput"
|
|
required
|
|
name="emailAddress"
|
|
label="Email or Username"
|
|
autocomplete="username"
|
|
></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>
|
|
<div class="form-footer">
|
|
Don't have an account?
|
|
<a @click=${async () => {
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
idpState.domtools.router.pushUrl('/register');
|
|
}}>Create one</a>
|
|
</div>
|
|
</idp-centercontainer>
|
|
`;
|
|
}
|
|
|
|
public async firstUpdated() {
|
|
await this.domtoolsPromise;
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
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;
|
|
const oidcContext = this.getOidcAuthorizationContext();
|
|
const setButtonText = async () => {
|
|
if (loginPasswordInput.value) {
|
|
loginSubmitButton.text = oidcContext ? 'Sign in and continue' : 'Login';
|
|
} else {
|
|
loginSubmitButton.text = 'Send magic link (or enter password)';
|
|
}
|
|
};
|
|
loginForm.addEventListener('idp-input-change', () => {
|
|
void setButtonText();
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
} else if (await idpState.idpClient.determineLoginStatus(false)) {
|
|
idpState.domtools.router.pushUrl('/dash/overview');
|
|
}
|
|
}
|
|
|
|
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
|
const loginSubmitButton = this.shadowRoot.querySelector(
|
|
'#loginSubmitButton'
|
|
) as plugins.idpCatalog.IdpFormSubmit;
|
|
loginSubmitButton.disabled = true;
|
|
|
|
const idpState = await IdpState.getSingletonInstance();
|
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
|
|
const loginRequestWithUsernameAndPassword =
|
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
|
'loginWithEmailOrUsernameAndPassword'
|
|
);
|
|
const loginRequestWithEmail =
|
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
|
'loginWithEmail'
|
|
);
|
|
|
|
if (valueArg.emailAddress && valueArg.passwordArg) {
|
|
loginForm.setStatus('pending', 'logging in...');
|
|
const response = await loginRequestWithUsernameAndPassword
|
|
.fire({
|
|
username: valueArg.emailAddress,
|
|
password: valueArg.passwordArg,
|
|
})
|
|
.catch(() => {
|
|
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
|
return null;
|
|
});
|
|
if (!response) {
|
|
loginSubmitButton.disabled = false;
|
|
return;
|
|
}
|
|
if (response.refreshToken) {
|
|
loginForm.setStatus('pending', 'obtained refreshToken...');
|
|
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
|
if (jwt) {
|
|
loginForm.setStatus('success', 'obtained jwt.');
|
|
const oidcHandled = await this.handleOidcAfterLogin(jwt);
|
|
if (!oidcHandled) {
|
|
idpState.domtools.router.pushUrl('/dash/overview');
|
|
}
|
|
} else {
|
|
loginForm.setStatus('error', 'something went wrong');
|
|
}
|
|
}
|
|
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
|
loginForm.setStatus('pending', 'sending magic link...');
|
|
const response = await loginRequestWithEmail.fire({
|
|
email: valueArg.emailAddress,
|
|
}).catch((err) => {
|
|
const message = err?.errorText || err?.message || 'Could not send the magic link. Please try again.';
|
|
loginForm.setStatus('error', message);
|
|
return null;
|
|
});
|
|
if (response?.status === 'ok') {
|
|
loginForm.setStatus('success', 'Please check your email!');
|
|
}
|
|
}
|
|
|
|
loginSubmitButton.disabled = false;
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
public async focus() {
|
|
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.idpCatalog.IdpInput).focus();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|