573 lines
17 KiB
JavaScript
573 lines
17 KiB
JavaScript
/**
|
|
* 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 */`
|
|
<div class="modal-overlay" id="modal-overlay">
|
|
<div class="modal-content">
|
|
<button class="modal-close" id="modal-close" aria-label="${t('common.close')}">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
|
|
${this.mode === 'login' ? this.renderLogin() : ''}
|
|
${this.mode === 'register' ? this.renderRegister() : ''}
|
|
${this.mode === 'show-uuid' ? this.renderShowUuid() : ''}
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
this.setupEventListeners()
|
|
}
|
|
|
|
renderLogin() {
|
|
const storedUuid = auth.getStoredUuid()
|
|
|
|
return /* html */`
|
|
<h2 class="modal-title">${t('auth.login')}</h2>
|
|
|
|
${this.error ? `<div class="auth-error">${this.error}</div>` : ''}
|
|
|
|
<form id="login-form" class="auth-form">
|
|
<div class="form-group">
|
|
<label class="label" for="uuid">${t('auth.yourUuid')}</label>
|
|
<input
|
|
type="text"
|
|
class="input"
|
|
id="uuid"
|
|
name="uuid"
|
|
value="${storedUuid || ''}"
|
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
required
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
>
|
|
</div>
|
|
|
|
${this.loginAttempts >= 3 ? `
|
|
<div class="form-group">
|
|
<pow-captcha id="login-captcha"></pow-captcha>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="remember-me" ${auth.getRememberMe() ? 'checked' : ''}>
|
|
<span>${t('auth.rememberMe')}</span>
|
|
</label>
|
|
<p class="field-hint">${t('auth.rememberMeHint')}</p>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary btn-lg btn-block" ${this.loading ? 'disabled' : ''}>
|
|
${this.loading ? t('auth.loggingIn') : t('auth.login')}
|
|
</button>
|
|
</form>
|
|
|
|
<div class="auth-footer">
|
|
<p>${t('auth.noAccount')}
|
|
<button class="link-btn" id="switch-register">${t('auth.createAccount')}</button>
|
|
</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
renderRegister() {
|
|
return /* html */`
|
|
<h2 class="modal-title">${t('auth.createAccount')}</h2>
|
|
|
|
<div class="auth-info">
|
|
<p>${t('auth.registerInfo')}</p>
|
|
</div>
|
|
|
|
${this.error ? `<div class="auth-error">${this.error}</div>` : ''}
|
|
|
|
${this.requireInviteCode ? `
|
|
<div class="form-group">
|
|
<label class="label" for="invite-code">${t('auth.inviteCode')}</label>
|
|
<input
|
|
type="text"
|
|
class="input"
|
|
id="invite-code"
|
|
name="invite-code"
|
|
placeholder="${t('auth.inviteCodePlaceholder')}"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
>
|
|
<p class="field-hint">${t('auth.inviteCodeHint')}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="form-group">
|
|
<pow-captcha id="register-captcha"></pow-captcha>
|
|
</div>
|
|
|
|
<button
|
|
class="btn btn-primary btn-lg btn-block"
|
|
id="generate-uuid"
|
|
${this.loading ? 'disabled' : ''}
|
|
>
|
|
${this.loading ? t('auth.creating') : t('auth.generateUuid')}
|
|
</button>
|
|
|
|
<div class="auth-footer">
|
|
<p>${t('auth.hasAccount')}
|
|
<button class="link-btn" id="switch-login">${t('auth.login')}</button>
|
|
</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
renderShowUuid() {
|
|
return /* html */`
|
|
<h2 class="modal-title">${t('auth.accountCreated')}</h2>
|
|
|
|
<div class="auth-warning">
|
|
<strong>${t('auth.important')}</strong>
|
|
<p>${t('auth.saveUuidWarning')}</p>
|
|
</div>
|
|
|
|
<div class="uuid-display">
|
|
<code id="uuid-value">${this.generatedUuid}</code>
|
|
<button class="btn btn-outline" id="copy-uuid" title="${t('auth.copy')}">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="uuid-actions">
|
|
<button class="btn btn-outline btn-lg" id="download-uuid">
|
|
${t('auth.downloadBackup')}
|
|
</button>
|
|
</div>
|
|
|
|
<button class="btn btn-primary btn-lg btn-block" id="confirm-saved">
|
|
${t('auth.confirmSaved')}
|
|
</button>
|
|
`
|
|
}
|
|
|
|
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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
|
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
|