feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,43 @@
{% extends 'email/base.html.twig' %}
{%- set subject = 'You\'ve been invited to ' ~ site_name %}
{%- do email.message.setSubject(subject) %}
{%- block content -%}
<h1>You're invited</h1>
<p>
Hi,
</p>
<p>
{{ actor ?: site_name }} has invited you to join <b>{{ site_name }}</b>.
</p>
{%- if message %}
<p style="white-space: pre-line;">{{ message }}</p>
{%- endif %}
<p>
Click the button below to set up your account and choose your password:
</p>
<p>
<br />
<a href="{{ invite_link }}" class="btn-primary">Accept invitation</a>
<br />
<br />
</p>
<p>
Or copy and paste this link into your browser's address bar:
</p>
<p class="word-break" style="word-break: break-all;">
<a href="{{ invite_link }}">{{ invite_link }}</a>
</p>
<p>
This invitation will expire soon, so please accept it promptly.
</p>
<p>
If you weren't expecting this invitation you can safely ignore this email.
</p>
<p>
<br />
{{ author ?: site_name }}
</p>
{%- endblock content -%}
@@ -0,0 +1,40 @@
{% extends 'email/base.html.twig' %}
{%- set subject = 'PLUGIN_LOGIN.FORGOT_EMAIL_SUBJECT'|t(site_name) %}
{%- do email.message.setSubject(subject) %}
{%- block content -%}
<h1>Password Reset</h1>
<p>
Hi {{ user.fullname ?? user.username }},
</p>
<p>
A password reset was requested for your admin account at <b>{{ site_name }}</b>.
</p>
<p>
Click the button below to choose a new password:
</p>
<p>
<br />
<a href="{{ reset_link }}" class="btn-primary">Reset my password</a>
<br />
<br />
</p>
<p>
Or copy and paste this link into your browser's address bar:
</p>
<p class="word-break" style="word-break: break-all;">
<a href="{{ reset_link }}">{{ reset_link }}</a>
</p>
<p>
This link will expire in 24 hours.
</p>
<p>
If you did not request this reset you can safely ignore this email — your password will not change.
</p>
<p>
<br />
{{ author ?: site_name }}
</p>
{%- endblock content -%}
@@ -0,0 +1,211 @@
{% set user_obj = object ?? grav.user %}
{% set api_keys = api_keys_for_user(user_obj.username) %}
{% set generate_url = uri.addNonce(base_url_relative ~ '/accounts/' ~ user_obj.username ~ '/task' ~ config.system.param_sep ~ 'apiKeyGenerate', 'admin-form', 'admin-nonce') %}
{% set revoke_url = uri.addNonce(base_url_relative ~ '/accounts/' ~ user_obj.username ~ '/task' ~ config.system.param_sep ~ 'apiKeyRevoke', 'admin-form', 'admin-nonce') %}
<div class="api-keys-manager" data-generate-url="{{ generate_url }}" data-revoke-url="{{ revoke_url }}">
{% if api_keys|length > 0 %}
<table class="api-keys-table noflex">
<thead>
<tr>
<th>{{ "Name"|t }}</th>
<th>{{ "Key Prefix"|t }}</th>
<th>{{ "Created"|t }}</th>
<th>{{ "Expires"|t }}</th>
<th>{{ "Last Used"|t }}</th>
<th class="right pad">{{ "Action"|t }}</th>
</tr>
</thead>
<tbody>
{% for key_data in api_keys %}
<tr data-key-id="{{ key_data.id }}">
<td><strong>{{ key_data.name ?? 'API Key' }}</strong></td>
<td><code class="api-key-prefix">{{ key_data.prefix ?? '—' }}</code></td>
<td>{{ key_data.created ? key_data.created|date('Y-m-d H:i') : 'N/A' }}</td>
<td>
{% if key_data.expires %}
{% if key_data.expires < "now"|date('U') %}
<span class="badge warning">Expired {{ key_data.expires|date('Y-m-d') }}</span>
{% else %}
{{ key_data.expires|date('Y-m-d') }}
{% endif %}
{% else %}
<em>Never</em>
{% endif %}
</td>
<td>{{ key_data.last_used ? key_data.last_used|date('Y-m-d H:i') : 'Never' }}</td>
<td class="right pad nowrap">
<button type="button"
class="button button-small danger api-key-revoke hint--bottom"
data-hint="Revoke Key"
data-key-id="{{ key_data.id }}"
data-key-name="{{ key_data.name }}">
<i class="fa fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="api-keys-empty">No API keys have been generated yet.</p>
{% endif %}
<div class="api-key-generate-form">
<div class="form-field grid pure-g">
<div class="form-label block size-1-3 pure-u-1-3">
<label>Generate New Key</label>
</div>
<div class="form-data block size-2-3 pure-u-2-3">
<div class="api-key-inputs">
<div class="form-input-wrapper">
<input type="text" id="api_key_name" class="api-key-name-input" placeholder="Key name (e.g. My CLI Tool)" />
</div>
<div class="api-key-options">
<div class="form-input-wrapper api-key-expiry-wrapper">
<input type="number" id="api_key_expiry" class="api-key-expiry-input" placeholder="Days" min="1" max="3650" />
<label for="api_key_expiry">expiry in days <em>(blank = never)</em></label>
</div>
<button type="button" class="button api-key-generate">
<i class="fa fa-key"></i> Generate Key
</button>
</div>
</div>
</div>
</div>
</div>
<div class="api-key-result" style="display: none;">
<div class="form-field grid pure-g">
<div class="form-label block size-1-3 pure-u-1-3">
<label><span class="tooltip" title="Save this key now — it cannot be shown again">New Key</span></label>
</div>
<div class="form-data block size-2-3 pure-u-2-3">
<div class="api-key-result-box">
<code class="api-key-value"></code>
<button type="button" class="button button-small api-key-copy hint--bottom" data-hint="Copy to clipboard">
<i class="fa fa-clipboard"></i>
</button>
</div>
<p class="form-field-description"><i class="fa fa-warning"></i> Copy this key now — it will not be shown again.</p>
</div>
</div>
</div>
</div>
<style>
.api-keys-table { width: 100%; margin-bottom: 0; }
.api-keys-table td, .api-keys-table th { padding: 0.5rem 0.75rem; }
.api-keys-table thead th { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; opacity: 0.7; }
.api-key-prefix { background: rgba(0,0,0,0.06); padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.85em; font-family: monospace; }
.api-keys-empty { color: #999; font-style: italic; padding: 0.5rem 0; }
.api-key-generate-form { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid rgba(0,0,0,0.07); }
.api-key-inputs { display: flex; flex-direction: column; gap: 0.5rem; }
.api-key-inputs .form-input-wrapper input[type="text"] { width: 100%; }
.api-key-options { display: flex; align-items: center; gap: 0.75rem; }
.api-key-expiry-wrapper { display: flex; align-items: center; gap: 0.5rem; }
.api-key-expiry-wrapper input { width: 80px; }
.api-key-expiry-wrapper label { font-size: 0.85em; color: #999; margin: 0; white-space: nowrap; }
.api-key-result { margin-top: 0.5rem; }
.api-key-result-box { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #fef9e7; border: 1px solid #f0d56e; border-radius: 4px; }
.api-key-result-box code { font-size: 1rem; font-family: monospace; word-break: break-all; flex: 1; color: #735c0f; user-select: all; }
.api-key-result .form-field-description { margin-top: 0.4rem; font-size: 0.85em; color: #b08800; }
.api-key-result .form-field-description .fa { margin-right: 0.3rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
var manager = document.querySelector('.api-keys-manager');
if (!manager) return;
var generateUrl = manager.dataset.generateUrl;
var revokeUrl = manager.dataset.revokeUrl;
// Generate key
var generateBtn = manager.querySelector('.api-key-generate');
if (generateBtn) {
generateBtn.addEventListener('click', function() {
var name = manager.querySelector('.api-key-name-input').value || 'API Key';
var expiryDays = manager.querySelector('.api-key-expiry-input').value;
this.disabled = true;
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Generating...';
var formData = new FormData();
formData.append('name', name);
if (expiryDays) {
formData.append('expiry_days', expiryDays);
}
var btn = this;
fetch(generateUrl, {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success' && data.key) {
var result = manager.querySelector('.api-key-result');
result.querySelector('.api-key-value').textContent = data.key;
result.style.display = '';
// Reload after a delay to show new key in table
setTimeout(function() { window.location.reload(); }, 5000);
} else {
alert(data.message || 'Failed to generate API key.');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-key"></i> Generate Key';
}
})
.catch(function(err) {
alert('Error generating key: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-key"></i> Generate Key';
});
});
}
// Copy to clipboard
manager.addEventListener('click', function(e) {
var copyBtn = e.target.closest('.api-key-copy');
if (!copyBtn) return;
var keyValue = manager.querySelector('.api-key-value').textContent;
navigator.clipboard.writeText(keyValue).then(function() {
copyBtn.innerHTML = '<i class="fa fa-check"></i>';
setTimeout(function() {
copyBtn.innerHTML = '<i class="fa fa-clipboard"></i>';
}, 2000);
});
});
// Revoke keys
manager.querySelectorAll('.api-key-revoke').forEach(function(btn) {
btn.addEventListener('click', function() {
var keyId = this.dataset.keyId;
var keyName = this.dataset.keyName;
if (!confirm('Revoke API key "' + keyName + '"? This cannot be undone.')) return;
var formData = new FormData();
formData.append('key_id', keyId);
fetch(revokeUrl, {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
window.location.reload();
} else {
alert(data.message || 'Failed to revoke key.');
}
})
.catch(function(err) {
alert('Error revoking key: ' + err.message);
});
});
});
});
</script>