/** * Auth Modal - Login/Register with UUID */ import { t, i18n } from '../i18n.js' import { auth } from '../services/auth.js' import { POW_SERVER } from '../services/pow-captcha.js' import './pow-captcha.js' class AuthModal extends HTMLElement { constructor() { super() this.mode = 'login' // 'login' | 'register' | 'show-uuid' this.generatedUuid = null this.error = null this.loading = false this.loginAttempts = 0 this.requireInviteCode = null } connectedCallback() { this.checkInviteCodeRequired() this.render() this.unsubscribe = i18n.subscribe(() => this.render()) this.boundHandleKeydown = this.handleKeydown.bind(this) } async checkInviteCodeRequired() { try { const res = await fetch(`${POW_SERVER}/invite/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ check: true }) }) const data = await res.json() this.requireInviteCode = !data.valid } catch { this.requireInviteCode = false } } disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() document.removeEventListener('keydown', this.boundHandleKeydown) } show(mode = 'login') { this.mode = mode this.error = null this.generatedUuid = null this.hidden = false this.render() document.body.style.overflow = 'hidden' // Focus on input after render requestAnimationFrame(() => { const input = this.querySelector('#uuid') if (input) input.focus() }) } hide() { this.hidden = true document.body.style.overflow = '' this.dispatchEvent(new CustomEvent('close')) } switchMode(mode) { this.mode = mode this.error = null this.render() } render() { if (this.hidden) { this.innerHTML = '' return } this.innerHTML = /* html */` ` this.setupEventListeners() } renderLogin() { const storedUuid = auth.getStoredUuid() return /* html */` ${this.error ? `
${this.error}
` : ''}
${this.loginAttempts >= 3 ? `
` : ''}

${t('auth.rememberMeHint')}

` } renderRegister() { return /* html */`

${t('auth.registerInfo')}

${this.error ? `
${this.error}
` : ''} ${this.requireInviteCode ? `

${t('auth.inviteCodeHint')}

` : ''}
` } renderShowUuid() { return /* html */`
${t('auth.important')}

${t('auth.saveUuidWarning')}

${this.generatedUuid}
` } setupEventListeners() { // Close modal this.querySelector('#modal-overlay')?.addEventListener('click', (e) => { if (e.target.id === 'modal-overlay') this.hide() }) this.querySelector('#modal-close')?.addEventListener('click', () => this.hide()) // Switch modes this.querySelector('#switch-register')?.addEventListener('click', () => this.switchMode('register')) this.querySelector('#switch-login')?.addEventListener('click', () => this.switchMode('login')) // Login form this.querySelector('#login-form')?.addEventListener('submit', (e) => this.handleLogin(e)) // Generate UUID this.querySelector('#generate-uuid')?.addEventListener('click', () => this.handleRegister()) // Copy UUID this.querySelector('#copy-uuid')?.addEventListener('click', () => this.copyUuid()) // Download backup this.querySelector('#download-uuid')?.addEventListener('click', () => this.downloadBackup()) // Confirm saved this.querySelector('#confirm-saved')?.addEventListener('click', () => { this.dispatchEvent(new CustomEvent('login', { detail: { success: true } })) this.hide() }) // Escape key document.addEventListener('keydown', this.boundHandleKeydown) } handleKeydown(e) { if (e.key === 'Escape' && !this.hidden) { this.hide() } } async handleLogin(e) { e.preventDefault() const uuid = this.querySelector('#uuid').value.trim() if (!uuid) { this.error = t('auth.enterUuid') this.render() return } // Check captcha after 3 failed attempts if (this.loginAttempts >= 3) { const captcha = this.querySelector('#login-captcha') if (!captcha?.isSolved()) { this.error = t('captcha.error') this.render() return } } this.loading = true this.error = null this.render() const rememberMe = this.querySelector('#remember-me')?.checked || false auth.setRememberMe(rememberMe) const result = await auth.login(uuid) this.loading = false if (result.success) { this.loginAttempts = 0 this.hide() this.dispatchEvent(new CustomEvent('login', { detail: { success: true } })) } else { this.loginAttempts++ this.error = result.error || t('auth.invalidUuid') this.render() } } async handleRegister() { // Validate invite code if required if (this.requireInviteCode) { const inviteInput = this.querySelector('#invite-code') const code = inviteInput?.value.trim() if (!code) { this.error = t('auth.inviteCodeRequired') this.render() return } try { const res = await fetch(`${POW_SERVER}/invite/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }) const data = await res.json() if (!data.valid) { const errorMap = { 'code_redeemed': t('auth.inviteCodeRedeemed'), 'code_expired': t('auth.inviteCodeExpired') } this.error = errorMap[data.error] || t('auth.inviteCodeInvalid') this.render() return } } catch { this.error = t('auth.inviteCodeInvalid') this.render() return } } // Require captcha for registration const captcha = this.querySelector('#register-captcha') if (!captcha?.isSolved()) { this.error = t('captcha.error') this.render() return } this.loading = true this.error = null // Update button state without full re-render (keeps captcha state) const btn = this.querySelector('#generate-uuid') if (btn) { btn.disabled = true btn.textContent = t('auth.creating') } const result = await auth.createAccount() this.loading = false if (result.success) { this.generatedUuid = result.uuid this.mode = 'show-uuid' this.render() this.dispatchEvent(new CustomEvent('register', { detail: { uuid: result.uuid } })) } else { this.error = result.error || t('auth.registrationFailed') this.render() } } async copyUuid() { try { await navigator.clipboard.writeText(this.generatedUuid) const btn = this.querySelector('#copy-uuid') btn.innerHTML = '' setTimeout(() => this.render(), 2000) } catch (e) { console.error('Copy failed:', e) } } downloadBackup() { const content = `kashilo.com Account Backup ======================== Your UUID (keep this secret!): ${this.generatedUuid} Login URL: https://kashilo.com/#/login Created: ${new Date().toISOString()} WARNING: If you lose this UUID, you cannot recover your account! ` const blob = new Blob([content], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `kashilo-backup-${this.generatedUuid.slice(0, 8)}.txt` a.click() URL.revokeObjectURL(url) } } customElements.define('auth-modal', AuthModal) const style = document.createElement('style') style.textContent = /* css */` auth-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: var(--z-modal); } auth-modal[hidden] { display: none; } auth-modal .modal-overlay { position: absolute; inset: 0; background: var(--color-overlay); display: flex; align-items: center; justify-content: center; padding: var(--space-md); } auth-modal .modal-content { background: var(--color-bg); border-radius: var(--radius-lg); padding: var(--space-xl); width: 100%; max-width: 480px; position: relative; box-shadow: var(--shadow-xl); } auth-modal .modal-close { position: absolute; top: var(--space-md); right: var(--space-md); background: none; border: none; cursor: pointer; color: var(--color-text-muted); padding: var(--space-xs); } auth-modal .modal-close:hover { color: var(--color-text); } auth-modal .modal-title { margin: 0 0 var(--space-lg); font-size: var(--font-size-2xl); } auth-modal .auth-form .form-group { margin-bottom: var(--space-lg); } auth-modal .auth-info { background: var(--color-bg-secondary); border-radius: var(--radius-md); padding: var(--space-md); margin-bottom: var(--space-lg); font-size: var(--font-size-sm); color: var(--color-text-secondary); } auth-modal .auth-warning { background: var(--color-bg-tertiary); border-left: 4px solid var(--color-accent); border-radius: var(--radius-md); padding: var(--space-md); margin-bottom: var(--space-lg); } auth-modal .auth-warning strong { display: block; margin-bottom: var(--space-xs); color: var(--color-accent); } auth-modal .auth-error { background: var(--color-bg-tertiary); border-left: 4px solid var(--color-error); border-radius: var(--radius-md); padding: var(--space-md); margin-bottom: var(--space-lg); color: var(--color-text); } auth-modal .uuid-display { display: flex; gap: var(--space-sm); margin-bottom: var(--space-lg); } auth-modal .uuid-display code { flex: 1; background: var(--color-bg-secondary); padding: var(--space-md); border-radius: var(--radius-md); font-family: monospace; font-size: var(--font-size-sm); word-break: break-all; border: 1px solid var(--color-border); } auth-modal .uuid-actions { margin-bottom: var(--space-lg); } auth-modal .btn-block { width: 100%; } auth-modal .auth-footer { margin-top: var(--space-lg); text-align: center; font-size: var(--font-size-sm); color: var(--color-text-muted); } auth-modal .link-btn { background: none; border: none; color: var(--color-accent); cursor: pointer; font-size: inherit; text-decoration: underline; } auth-modal .link-btn:hover { color: var(--color-accent-hover); } auth-modal .checkbox-label { display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; font-size: var(--font-size-sm); } auth-modal .checkbox-label input { width: 16px; height: 16px; accent-color: var(--color-accent); } auth-modal .field-hint { font-size: var(--font-size-xs); color: var(--color-text-muted); margin-top: var(--space-xs); } ` document.head.appendChild(style) export default AuthModal