Browse Source

Login: simplify, manage actions, signer login flow in modal

imwald
Nuša Pukšič 4 days ago
parent
commit
6be2e1f951
  1. 88
      assets/controllers/editor/layout_controller.js
  2. 11
      assets/controllers/utility/login_controller.js
  3. 148
      assets/controllers/utility/signer_modal_controller.js
  4. 2
      assets/styles/02-layout/layout.css
  5. 3
      assets/styles/editor-layout.css
  6. 3
      src/Twig/Components/Footer.php
  7. 2
      src/Twig/Components/Header.php
  8. 7
      src/Twig/Components/UserMenu.php
  9. 25
      templates/components/SignerModal.html.twig
  10. 14
      templates/components/UserMenu.html.twig
  11. 15
      templates/editor/panels/_articlelist.html.twig
  12. 5
      templates/login/index.html.twig

88
assets/controllers/editor/layout_controller.js

@ -14,6 +14,9 @@ export default class extends Controller { @@ -14,6 +14,9 @@ export default class extends Controller {
console.log('Editor layout controller connected');
this.autoSaveTimer = null;
// Expose method globally so login controllers can save state before reload
window.saveEditorStateBeforeLogin = () => this.saveCompleteStateBeforeLogin();
// --- Editor State Object ---
// See documentation/Editor/Reactivity-and-state-management.md
this.state = {
@ -23,6 +26,10 @@ export default class extends Controller { @@ -23,6 +26,10 @@ export default class extends Controller {
content_event_json: {} // Derived event JSON
};
this.hydrateState();
// Check for and restore state after login
this.restoreStateAfterLogin();
this.updateMarkdownEditor();
this.updateQuillEditor();
@ -114,6 +121,87 @@ export default class extends Controller { @@ -114,6 +121,87 @@ export default class extends Controller {
if (nmdField) nmdField.value = this.state.content_NMD || '';
}
saveCompleteStateBeforeLogin() {
// Save all editor content and form fields before login/reload
console.log('[Editor] Saving complete editor state before login');
// Sync current content first
this.syncContentBeforePublish();
const stateToSave = {
timestamp: Date.now(),
editorState: this.state,
formData: {}
};
// Save all form fields
const form = this.element.querySelector('form');
if (form) {
const formData = new FormData(form);
for (const [key, value] of formData.entries()) {
stateToSave.formData[key] = value;
}
}
localStorage.setItem('editorStateBeforeLogin', JSON.stringify(stateToSave));
console.log('[Editor] State saved:', stateToSave);
}
restoreStateAfterLogin() {
// Check if there's a saved state from before login
const savedStateStr = localStorage.getItem('editorStateBeforeLogin');
if (!savedStateStr) {
console.log('[Editor] No saved state found');
return;
}
try {
const savedState = JSON.parse(savedStateStr);
const ageMinutes = (Date.now() - savedState.timestamp) / 1000 / 60;
// Only restore if saved within last 10 minutes
if (ageMinutes > 10) {
console.log('[Editor] Saved state too old, ignoring');
localStorage.removeItem('editorStateBeforeLogin');
return;
}
console.log('[Editor] Restoring state from before login:', savedState);
// Restore editor state
if (savedState.editorState) {
this.state = savedState.editorState;
}
// Restore form fields
if (savedState.formData) {
const form = this.element.querySelector('form');
if (form) {
for (const [key, value] of Object.entries(savedState.formData)) {
const field = form.elements[key];
if (field) {
if (field.type === 'checkbox') {
field.checked = value === 'on' || value === '1' || value === true;
} else {
field.value = value;
}
}
}
}
}
// Clear the saved state
localStorage.removeItem('editorStateBeforeLogin');
// Show notification
this.updateStatus('✓ Your work has been restored after login');
} catch (e) {
console.error('[Editor] Failed to restore state:', e);
localStorage.removeItem('editorStateBeforeLogin');
}
}
// --- Tab Switching Logic ---
switchMode(event) {
const mode = event.currentTarget.dataset.mode;

11
assets/controllers/utility/login_controller.js

@ -1,12 +1,8 @@ @@ -1,12 +1,8 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
export default class extends Controller {
static targets = ['nostrError'];
async initialize() {
this.component = await getComponent(this.element);
}
async loginAct(event) {
if (!window.nostr) {
if (this.hasNostrErrorTarget) {
@ -45,8 +41,13 @@ export default class extends Controller { @@ -45,8 +41,13 @@ export default class extends Controller {
if (!response.ok) return false;
return 'Authentication Successful';
})
if (!!result) {
this.component.render();
// Save editor state before reload (if in editor)
if (typeof window.saveEditorStateBeforeLogin === 'function') {
window.saveEditorStateBeforeLogin();
}
window.location.reload();
}
}
}

148
assets/controllers/utility/signer_modal_controller.js

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
import { Controller } from '@hotwired/stimulus';
import { getPublicKey, SimplePool } from 'nostr-tools';
import { BunkerSigner } from "nostr-tools/nip46";
import { setRemoteSignerSession } from '../nostr/signer_manager.js';
export default class extends Controller {
static targets = ['dialog', 'qr', 'status'];
connect() {
console.log('[signer-modal] controller connected');
this._localSecretKey = null;
this._uri = null;
this._relays = [];
this._secret = null;
this._signer = null;
this._pool = null;
this._didAuth = false;
}
disconnect() {
try { this._signer?.close?.(); } catch (_) {}
try { this._pool?.close?.([]); } catch (_) {}
}
async openDialog() {
console.log('[signer-modal] openDialog called', this.hasDialogTarget);
if (this.hasDialogTarget) {
this.dialogTarget.style.display = 'block';
await this._init();
} else {
console.error('[signer-modal] dialog target not found');
}
}
closeDialog() {
if (this.hasDialogTarget) {
this.dialogTarget.style.display = 'none';
// Clean up resources
try { this._signer?.close?.(); } catch (_) {}
try { this._pool?.close?.([]); } catch (_) {}
this._didAuth = false;
}
}
async _init() {
try {
this._setStatus('Requesting pairing QR…');
const res = await fetch('/nostr-connect/qr');
if (!res.ok) throw new Error('QR fetch failed');
const data = await res.json();
this._localSecretKey = data.privkey;
this._uri = data.uri;
this._relays = data.relays || [data.relay].filter(Boolean);
this._secret = data.secret || null;
if (this.hasQrTarget) {
this.qrTarget.innerHTML = `<img alt="Amber pairing QR" src="${data.qr}" style="width:260px;height:260px;" />`;
}
this._checkClientPubkeyIntegrity();
this._setStatus('Scan with Amber (NIP-46)…');
await this._createSigner();
this._setStatus('Remote signer connected. Authenticating…');
await this._attemptAuth();
} catch (e) {
console.error('[signer-modal] init error', e);
this._setStatus('Init failed: ' + (e.message || 'unknown'));
}
}
_checkClientPubkeyIntegrity() {
try {
if (!this._localSecretKey || !this._uri) return;
const derived = getPublicKey(this._localSecretKey);
const m = this._uri.match(/^nostrconnect:\/\/([0-9a-fA-F]{64})/);
if (!m) {
console.warn('[signer-modal] URI missing/invalid pubkey segment');
return;
}
const uriPk = m[1].toLowerCase();
if (uriPk !== derived.toLowerCase()) {
console.warn('[signer-modal] Pubkey mismatch: derived != URI', { derived, uriPk });
}
} catch (e) {
console.warn('[signer-modal] integrity check failed', e);
}
}
async _createSigner() {
this._pool = new SimplePool();
this._setStatus('Waiting for remote signer…');
this._signer = await BunkerSigner.fromURI(this._localSecretKey, this._uri, { pool: this._pool });
}
async _attemptAuth() {
if (this._didAuth) return;
this._didAuth = true;
try {
const loginEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin + '/login'],
['method', 'POST'],
['t', 'bunker']
],
content: ''
};
this._setStatus('Signing login request…');
const signed = await this._signer.signEvent(loginEvent);
this._setStatus('Submitting login…');
const resp = await fetch('/login', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) }
});
if (resp.ok) {
setRemoteSignerSession({
privkey: this._localSecretKey,
bunkerPointer: this._signer.bp,
uri: this._uri,
relays: this._relays,
secret: this._secret
});
this._setStatus('Authenticated. Reloading…');
// Save editor state before reload (if in editor)
if (typeof window.saveEditorStateBeforeLogin === 'function') {
window.saveEditorStateBeforeLogin();
}
setTimeout(() => window.location.reload(), 500);
} else {
this._setStatus('Login failed (' + resp.status + ')');
}
} catch (e) {
console.error('[signer-modal] auth error', e);
this._setStatus('Auth error: ' + (e.message || 'unknown'));
}
}
_setStatus(msg) {
if (this.hasStatusTarget) this.statusTarget.textContent = msg;
}
}

2
assets/styles/02-layout/layout.css

@ -284,7 +284,7 @@ p.measure { @@ -284,7 +284,7 @@ p.measure {
margin: 1em auto;
}
section{ position: relative; padding: var(--section-spacing) var(--spacing-3); }
section{ padding: var(--section-spacing) var(--spacing-3); }
.katex-display>.katex {
line-height: 2;

3
assets/styles/editor-layout.css

@ -448,10 +448,7 @@ main.editor-layout { @@ -448,10 +448,7 @@ main.editor-layout {
}
.articlelist-placeholder {
color: #bbb;
font-style: italic;
padding: 1.5rem 0;
text-align: center;
}
/* Filesystem-style reading list folders */

3
src/Twig/Components/Footer.php

@ -8,7 +8,4 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -8,7 +8,4 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Footer {
public function __construct()
{
}
}

2
src/Twig/Components/Header.php

@ -4,8 +4,6 @@ declare(strict_types=1); @@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Twig\Components;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]

7
src/Twig/Components/UserMenu.php

@ -1,12 +1,9 @@ @@ -1,12 +1,9 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
#[AsTwigComponent]
class UserMenu
{
use DefaultActionTrait;
}

25
templates/components/SignerModal.html.twig

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<div data-utility--signer-modal-target="dialog" class="iu-dialog" style="display:none;">
<div class="iu-backdrop" data-action="click->utility--signer-modal#closeDialog"></div>
<div class="iu-modal">
<div class="modal-header">
<h5>Remote Signer Login</h5>
<button type="button" class="close" data-action="click->utility--signer-modal#closeDialog">&times;</button>
</div>
<div class="modal-body">
<p class="text-muted mb-3">Scan the QR below with a NIP-46 compatible bunker signer to pair a remote signing session.</p>
<div class="row">
<div class="col-md-6 text-center mb-3">
<div data-utility--signer-modal-target="qr" class="mb-2"></div>
</div>
<div class="col-md-6">
<h6>Status</h6>
<div data-utility--signer-modal-target="status" class="small text-muted">Initializing…</div>
<hr>
<p class="small text-muted mb-1">After pairing, any page that uses window.nostr will automatically use this remote signer session.</p>
<p class="small text-muted">If pairing stalls, close and reopen this dialog to generate a new ephemeral key.</p>
</div>
</div>
</div>
</div>
</div>

14
templates/components/UserMenu.html.twig

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
<div {{ attributes.defaults(stimulus_controller('utility--login')) }}>
<div {{ attributes }}>
<div data-controller="utility--login utility--signer-modal">
{% if app.user %}
<div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
@ -22,7 +23,7 @@ @@ -22,7 +23,7 @@
<li>
<a href="/logout"
data-controller="nostr--logout"
data-action="click->nostr--logout#handleLogout live#$render">{{ 'heading.logout'|trans }}</a>
data-action="click->nostr--logout#handleLogout">{{ 'heading.logout'|trans }}</a>
</li>
</ul>
{% else %}
@ -36,14 +37,11 @@ @@ -36,14 +37,11 @@
<twig:Atoms:Button {{ ...stimulus_action('utility--login', 'loginAct') }} tag="a" variant="accent">Extension</twig:Atoms:Button>
</li>
<li>
<twig:Atoms:Button href="{{ path('app_login_signer') }}" tag="a" variant="accent">Signer</twig:Atoms:Button>
<twig:Atoms:Button {{ ...stimulus_action('utility--signer-modal', 'openDialog') }} tag="a" variant="accent">Signer</twig:Atoms:Button>
</li>
</ul>
</div>
<twig:SignerModal />
{% endif %}
<div>
<div class="spinner" data-loading>
<div class="lds-dual-ring"></div>
</div>
</div>
</div>
</div>

15
templates/editor/panels/_articlelist.html.twig

@ -81,7 +81,20 @@ @@ -81,7 +81,20 @@
{% endfor %}
</ul>
{% else %}
<div class="articlelist-placeholder">Sign in to see your articles.</div>
<div data-controller="utility--login utility--signer-modal">
<div class="articlelist-placeholder">Sign in to see your articles.</div>
<ul class="list-unstyled">
<li class="mb-2">
<div data-utility--login-target="nostrError" class="nostr-error-message" style="display:none;color:#b00;margin-bottom:0.5em;"></div>
<twig:Atoms:Button {{ ...stimulus_action('utility--login', 'loginAct') }} tag="a" variant="accent">Extension</twig:Atoms:Button>
</li>
<li>
<twig:Atoms:Button {{ ...stimulus_action('utility--signer-modal', 'openDialog') }} tag="a" variant="accent">Signer</twig:Atoms:Button>
</li>
</ul>
<twig:SignerModal />
</div>
{% endif %}
</div>
</div>

5
templates/login/index.html.twig

@ -9,11 +9,12 @@ @@ -9,11 +9,12 @@
<div class="alert alert-success">You are already authenticated.</div>
{% else %}
<div class="card">
<div class="card-body" data-controller="utility--login">
<div class="card-body" data-controller="utility--login utility--signer-modal">
<p class="text-muted small mb-3">Use your Nostr credentials to authenticate.</p>
<button class="btn btn--primary" data-action="click->utility--login#loginAct">Login with Extension</button>
<a class="btn btn--primary" href="{{ path('app_login_signer') }}">Login with a remote signer</a>
<button class="btn btn--primary" data-action="click->utility--signer-modal#openDialog">Login with a remote signer</button>
</div>
<twig:SignerModal />
</div>
{% endif %}
</div>

Loading…
Cancel
Save