components_login_dialog.js
import { firebaseAuth, signInWithEmailAndPassword } from '../firebase.js';
/**
* Login dialog with Sign In and Create Account views.
*/
export class LoginDialog {
/** Create a new LoginDialog instance. */
constructor() {
this._overlay = null;
this._isRequired = false;
}
/** Show the dialog as a dismissable overlay. */
open() {
if (this._overlay) return;
this._isRequired = false;
this._overlay = this._build();
document.body.appendChild(this._overlay);
this._overlay.querySelector('.login-email-input').focus();
}
/**
* Show the dialog as a blocking gate — no close button, no backdrop dismiss.
* Use this when a sign-in is required to access the app.
*/
openRequired() {
if (this._overlay) return;
this._isRequired = true;
this._overlay = this._build();
document.body.appendChild(this._overlay);
this._overlay.querySelector('.login-email-input').focus();
}
/** Hide and destroy the dialog. */
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
this._isRequired = false;
}
}
/**
* Show a server-side sign-in error and re-enable the form.
* Called when Firebase auth succeeded but the backend rejected the user.
* No-op if the dialog is not open.
* @param {string} msg - The error message to display.
*/
showConnectError(msg) {
if (!this._overlay) return;
const submitBtn = this._overlay._signInSubmit;
const errorMsg = this._overlay._signInError;
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Sign In'; }
if (errorMsg) { errorMsg.textContent = msg; }
}
// ── Private ─────────────────────────────────────────────────────────────
/**
* Build and return the dialog overlay element.
* @returns {HTMLElement} The overlay containing the modal.
*/
_build() {
const overlay = document.createElement('div');
overlay.className = 'login-dialog-overlay';
const modal = document.createElement('div');
modal.className = 'login-dialog-modal';
// ── Header ──────────────────────────────────────────────────────────
const header = document.createElement('div');
header.className = 'login-dialog-header';
const title = document.createElement('span');
title.className = 'login-dialog-title';
title.textContent = 'Sign In';
header.appendChild(title);
if (!this._isRequired) {
const closeBtn = document.createElement('button');
closeBtn.className = 'login-dialog-close';
closeBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
closeBtn.title = 'Close';
closeBtn.addEventListener('click', () => this.close());
header.appendChild(closeBtn);
}
modal.appendChild(header);
// ── Sign-in view ─────────────────────────────────────────────────────
const viewSignIn = document.createElement('div');
viewSignIn.className = 'login-dialog-panel active';
const emailInput = document.createElement('input');
emailInput.type = 'email';
emailInput.className = 'login-dialog-input login-email-input';
emailInput.placeholder = 'Email';
emailInput.autocomplete = 'email';
const passwordInput = document.createElement('input');
passwordInput.type = 'password';
passwordInput.className = 'login-dialog-input login-password-input';
passwordInput.placeholder = 'Password';
passwordInput.autocomplete = 'current-password';
const signInBtn = document.createElement('button');
signInBtn.className = 'login-dialog-submit';
signInBtn.textContent = 'Sign In';
const signInError = document.createElement('div');
signInError.className = 'login-dialog-error';
const createAccountBtn = document.createElement('button');
createAccountBtn.className = 'login-dialog-secondary-btn';
createAccountBtn.textContent = 'Create Account';
viewSignIn.appendChild(emailInput);
viewSignIn.appendChild(passwordInput);
viewSignIn.appendChild(signInBtn);
viewSignIn.appendChild(signInError);
viewSignIn.appendChild(createAccountBtn);
// Store refs for showConnectError
overlay._signInSubmit = signInBtn;
overlay._signInError = signInError;
// ── Create-account view ──────────────────────────────────────────────
const viewCreate = document.createElement('div');
viewCreate.className = 'login-dialog-panel';
const notice = document.createElement('div');
notice.className = 'login-dialog-notice';
notice.innerHTML = `
<span class="login-dialog-notice-badge">Hosted Cloud Service</span>
<p>Create an account to use the hosted version of Waveform Studio, which includes AI transcription
and cloud storage. The hosted service is currently invite-only — you'll receive an email when
your account is activated.</p>
<p style="margin-top:0.5rem;">Prefer to run it yourself? The app is
<a href="https://gitlab.com/vtleavs/waveform-studio/" target="_blank" rel="noopener noreferrer"
style="color:var(--accent);">open source</a> and free to self-host.</p>`;
viewCreate.appendChild(notice);
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'login-dialog-input';
nameInput.placeholder = 'Display name';
nameInput.autocomplete = 'name';
const caEmailInput = document.createElement('input');
caEmailInput.type = 'email';
caEmailInput.className = 'login-dialog-input';
caEmailInput.placeholder = 'Email address';
caEmailInput.autocomplete = 'email';
const caPasswordInput = document.createElement('input');
caPasswordInput.type = 'password';
caPasswordInput.className = 'login-dialog-input';
caPasswordInput.placeholder = 'Password (min 8 chars)';
caPasswordInput.autocomplete = 'new-password';
const caConfirmInput = document.createElement('input');
caConfirmInput.type = 'password';
caConfirmInput.className = 'login-dialog-input';
caConfirmInput.placeholder = 'Confirm password';
caConfirmInput.autocomplete = 'new-password';
const requestBtn = document.createElement('button');
requestBtn.className = 'login-dialog-submit';
requestBtn.textContent = 'Request Access';
const caError = document.createElement('div');
caError.className = 'login-dialog-error';
const backBtn = document.createElement('button');
backBtn.className = 'login-dialog-secondary-btn';
backBtn.textContent = 'Back to Sign In';
viewCreate.appendChild(nameInput);
viewCreate.appendChild(caEmailInput);
viewCreate.appendChild(caPasswordInput);
viewCreate.appendChild(caConfirmInput);
viewCreate.appendChild(requestBtn);
viewCreate.appendChild(caError);
viewCreate.appendChild(backBtn);
// ── Create-account success view ──────────────────────────────────────
const viewSuccess = document.createElement('div');
viewSuccess.className = 'login-dialog-panel login-dialog-success';
const successIcon = document.createElement('div');
successIcon.className = 'login-dialog-success-icon';
successIcon.innerHTML = '<span class="icon icon-check" style="width:32px;height:32px;"></span>';
const successTitle = document.createElement('p');
successTitle.className = 'login-dialog-success-title';
successTitle.textContent = 'Request received';
const successBody = document.createElement('p');
successBody.className = 'login-dialog-success-body';
successBody.textContent = "The administrators have been notified. You'll receive an email if your account is activated.";
const successBackBtn = document.createElement('button');
successBackBtn.className = 'login-dialog-secondary-btn';
successBackBtn.textContent = 'Back to Sign In';
viewSuccess.appendChild(successIcon);
viewSuccess.appendChild(successTitle);
viewSuccess.appendChild(successBody);
viewSuccess.appendChild(successBackBtn);
modal.appendChild(viewSignIn);
modal.appendChild(viewCreate);
modal.appendChild(viewSuccess);
// ── View switching ───────────────────────────────────────────────────
const showSignIn = () => {
title.textContent = 'Sign In';
viewSignIn.classList.add('active');
viewCreate.classList.remove('active');
viewSuccess.classList.remove('active');
emailInput.focus();
};
const showCreate = () => {
title.textContent = 'Create Account';
viewSignIn.classList.remove('active');
viewCreate.classList.add('active');
viewSuccess.classList.remove('active');
nameInput.focus();
};
const showSuccess = () => {
title.textContent = 'Create Account';
viewSignIn.classList.remove('active');
viewCreate.classList.remove('active');
viewSuccess.classList.add('active');
};
createAccountBtn.addEventListener('click', showCreate);
backBtn.addEventListener('click', showSignIn);
successBackBtn.addEventListener('click', showSignIn);
// ── Sign-in logic ────────────────────────────────────────────────────
const doSignIn = async () => {
const email = emailInput.value.trim();
const password = passwordInput.value;
if (!email || !password) {
signInError.textContent = 'Please enter your email and password.';
return;
}
if (!firebaseAuth) {
signInError.textContent = 'Firebase is not configured on this server.';
return;
}
signInBtn.disabled = true;
signInError.textContent = '';
try {
await signInWithEmailAndPassword(firebaseAuth, email, password);
// Keep the dialog open with a connecting state.
// onAuthStateChanged in main.js handles the server connection and
// will call close() on success or showConnectError() on failure.
signInBtn.textContent = 'Connecting…';
} catch (e) {
signInError.textContent = _friendlyFirebaseError(e.code);
signInBtn.disabled = false;
}
};
signInBtn.addEventListener('click', doSignIn);
passwordInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSignIn();
e.stopPropagation();
});
emailInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') passwordInput.focus();
e.stopPropagation();
});
// ── Create-account logic ─────────────────────────────────────────────
const doCreateAccount = async () => {
const name = nameInput.value.trim();
const email = caEmailInput.value.trim();
const password = caPasswordInput.value;
const confirm = caConfirmInput.value;
caError.textContent = '';
if (!email) { caError.textContent = 'Email is required.'; return; }
if (password.length < 8) { caError.textContent = 'Password must be at least 8 characters.'; return; }
if (password !== confirm) { caError.textContent = 'Passwords do not match.'; return; }
requestBtn.disabled = true;
requestBtn.textContent = 'Submitting…';
try {
const res = await fetch('/api/beta-signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (res.ok) {
showSuccess();
} else {
caError.textContent = data.error || 'Something went wrong. Please try again.';
requestBtn.disabled = false;
requestBtn.textContent = 'Request Access';
}
} catch {
caError.textContent = 'Network error. Check your connection and try again.';
requestBtn.disabled = false;
requestBtn.textContent = 'Request Access';
}
};
requestBtn.addEventListener('click', doCreateAccount);
[nameInput, caEmailInput, caPasswordInput].forEach(input => {
input.addEventListener('keydown', (e) => { e.stopPropagation(); });
});
caConfirmInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doCreateAccount();
e.stopPropagation();
});
// TODO: Custom server sign-in has been disabled pending review.
// Re-enable by adding a tab that switches to a custom-server panel where
// the user can supply an alternate base URL and authenticate against it.
if (this._isRequired) {
overlay.classList.add('login-dialog-overlay--required');
const wrap = document.createElement('div');
wrap.className = 'login-dialog-branded-wrap';
const brandName = document.createElement('div');
brandName.className = 'login-dialog-brand-name';
brandName.textContent = 'Waveform Studio';
wrap.appendChild(brandName);
wrap.appendChild(modal);
overlay.appendChild(wrap);
} else {
overlay.appendChild(modal);
}
// Close on outside click (only when mousedown also started on the overlay, and not in required mode)
if (!this._isRequired) {
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) this.close();
});
}
return overlay;
}
}
/**
* Map a Firebase auth error code to a human-readable message.
* @param {string} code - The Firebase error code (e.g. 'auth/wrong-password').
* @returns {string} A user-facing error message.
*/
function _friendlyFirebaseError(code) {
switch (code) {
case 'auth/invalid-email': return 'Invalid email address.';
case 'auth/user-not-found':
case 'auth/wrong-password':
case 'auth/invalid-credential': return 'Incorrect email or password.';
case 'auth/too-many-requests': return 'Too many attempts. Please try again later.';
case 'auth/user-disabled': return 'This account has been disabled.';
case 'auth/network-request-failed': return 'Network error. Check your connection.';
default: return 'Sign-in failed. Please try again.';
}
}