feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user