Files
app/ts_web/elements/idp-loginprompt.ts
T

565 lines
16 KiB
TypeScript
Raw Normal View History

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';
import '@uptime.link/webwidget';
2024-09-29 13:56:38 +02:00
import '@design.estate/dees-catalog';
import { DeesForm, DeesFormSubmit, DeesInputText } from '@design.estate/dees-catalog';
import { IdpState } from '../states/idp.state.js';
2024-09-29 13:56:38 +02:00
declare global {
interface HTMLElementTagNameMap {
'idp-loginprompt': IdpLoginPrompt;
2024-09-29 13:56:38 +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
@state()
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
@state()
accessor oidcConsentError = '';
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,
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();
}
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 DeesForm | 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 DeesForm | 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;
}
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;
}
dees-form {
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
}
.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-button {
border: none;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
cursor: pointer;
}
.consent-button-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--foreground);
}
.consent-button-primary {
background: linear-gradient(135deg, #9b7bff, #5fd1ff);
color: #0a0a0a;
font-weight: 600;
}
.consent-error {
color: #ff9a9a;
font-size: 14px;
}
2024-09-29 13:56:38 +02:00
`,
];
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">
<button
class="consent-button consent-button-secondary"
@click=${() => {
this.redirectOidcError('access_denied');
}}
>
Cancel
</button>
<button
class="consent-button consent-button-primary"
@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
</button>
</div>
</div>
</idp-centercontainer>
`;
}
2024-09-29 13:56:38 +02:00
return html`
<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>
<dees-form
id="loginForm"
@formData=${(eventArg) => {
2025-11-30 22:35:24 +00:00
this.login({
emailAddress: eventArg.detail.data.emailAddress,
passwordArg: eventArg.detail.data.password,
});
}}
2025-11-30 22:35:24 +00:00
>
<dees-input-text
id="loginEmailInput"
.required=${true}
key="emailAddress"
label="Email or Username"
></dees-input-text>
<dees-input-text
.id=${'loginPasswordInput'}
.key=${'password'}
.label=${'Password'}
.isPasswordBool=${true}
></dees-input-text>
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
</dees-form>
<div class="form-footer">
Don't have an account?
<a @click=${async () => {
const idpState = await IdpState.getSingletonInstance();
idpState.domtools.router.pushUrl('/register');
2025-11-30 22:35:24 +00:00
}}>Create one</a>
</div>
</idp-centercontainer>
2024-09-29 13:56:38 +02:00
`;
}
public async firstUpdated() {
await this.domtoolsPromise;
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as DeesInputText;
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as DeesFormSubmit;
const oidcContext = this.getOidcAuthorizationContext();
2024-09-29 13:56:38 +02:00
const setButtonText = async () => {
if (loginPasswordInput.value) {
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)';
}
};
loginForm.changeSubject.subscribe(() => {
void setButtonText();
2024-09-29 13:56:38 +02: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);
}
}
}
2024-09-29 13:56:38 +02:00
}
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
const loginSubmitButton = this.shadowRoot.querySelector(
'#loginSubmitButton'
) as plugins.deesCatalog.DeesFormSubmit;
loginSubmitButton.disabled = true;
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
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({
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!');
return null;
2024-09-29 13:56:38 +02:00
});
if (!response) {
loginSubmitButton.disabled = false;
2024-09-29 13:56:38 +02:00
return;
}
if (response.refreshToken) {
loginForm.setStatus('pending', 'obtained refreshToken...');
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
2024-09-29 13:56:38 +02:00
if (jwt) {
loginForm.setStatus('success', 'obtained jwt.');
const oidcHandled = await this.handleOidcAfterLogin(jwt);
if (!oidcHandled) {
idpState.domtools.router.pushUrl('/account');
}
2024-09-29 13:56:38 +02:00
} 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,
});
if (response.status === 'ok') {
loginForm.setStatus('success', 'Please check your email!');
}
}
loginSubmitButton.disabled = false;
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);
}
}
public async focus() {
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText).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();
}
2024-09-29 13:56:38 +02:00
}