mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
feat(ui): surface the agency portal handoff at token creation (#73)
When an agency token is created, the once-shown secret box now also shows the Portal URL (window.location.origin + '/agency' — the real public host the admin is on, correct behind Cloudflare, config-free) and a COPYABLE INVITE: "Go to <url> and paste this access key: <key>". The key lives in the invite TEXT, never in a URL — no magic link, because Cloudflare logs query strings and chat apps unfurl links (the key would leak on paste). Same exposure as the key field itself, just with the destination surfaced. The existing "won't see it again" warning now covers the invite too (it contains the key). i18n x5 (parity test). Skipped the optional per-row portal URL in the token list: it's the same /agency for every agency token, so per-row it's noise; the creation invite + the /docs link cover discovery. Confirmed: invite copy button copies the full "go here + paste key" text; /agency resolves (200); i18n parity + full suite green (149). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
02859eb1aa
commit
ed45a9a23d
|
|
@ -354,6 +354,10 @@ export default {
|
||||||
'apitoken.title': 'API-Tokens',
|
'apitoken.title': 'API-Tokens',
|
||||||
'apitoken.desc': 'Persönliche Zugriffstokens für die öffentliche API, beschränkt auf diesen Arbeitsbereich. Behandeln Sie sie wie Passwörter – wer das Token hat, kann hier in Ihrem Namen handeln.',
|
'apitoken.desc': 'Persönliche Zugriffstokens für die öffentliche API, beschränkt auf diesen Arbeitsbereich. Behandeln Sie sie wie Passwörter – wer das Token hat, kann hier in Ihrem Namen handeln.',
|
||||||
'apitoken.docs_link': 'Neu bei der API? Zur vollständigen Dokumentation →',
|
'apitoken.docs_link': 'Neu bei der API? Zur vollständigen Dokumentation →',
|
||||||
|
'apitoken.portal_url_label': 'Agentur-Portal-URL',
|
||||||
|
'apitoken.invite_label': 'Einladung zum Kopieren — an die Agentur senden:',
|
||||||
|
'apitoken.invite_text': 'Gehe zu {url} und füge diesen Zugriffsschlüssel ein: {key}',
|
||||||
|
'apitoken.copy_invite': 'Einladung kopieren',
|
||||||
'apitoken.name_placeholder': 'z. B. Agentur-Integration',
|
'apitoken.name_placeholder': 'z. B. Agentur-Integration',
|
||||||
'apitoken.scope_read': 'Nur Lesen',
|
'apitoken.scope_read': 'Nur Lesen',
|
||||||
'apitoken.scope_write': 'Lesen & Schreiben',
|
'apitoken.scope_write': 'Lesen & Schreiben',
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,10 @@ export default {
|
||||||
'apitoken.title': 'API Tokens',
|
'apitoken.title': 'API Tokens',
|
||||||
'apitoken.desc': 'Personal access tokens for the public API, scoped to this workspace. Treat them like passwords — anyone with the token can act as you here.',
|
'apitoken.desc': 'Personal access tokens for the public API, scoped to this workspace. Treat them like passwords — anyone with the token can act as you here.',
|
||||||
'apitoken.docs_link': 'New to the API? See the full documentation →',
|
'apitoken.docs_link': 'New to the API? See the full documentation →',
|
||||||
|
'apitoken.portal_url_label': 'Agency portal URL',
|
||||||
|
'apitoken.invite_label': 'Copyable invite — send this to the agency:',
|
||||||
|
'apitoken.invite_text': 'Go to {url} and paste this access key: {key}',
|
||||||
|
'apitoken.copy_invite': 'Copy invite',
|
||||||
'apitoken.name_placeholder': 'e.g. Agency integration',
|
'apitoken.name_placeholder': 'e.g. Agency integration',
|
||||||
'apitoken.scope_read': 'Read only',
|
'apitoken.scope_read': 'Read only',
|
||||||
'apitoken.scope_write': 'Read & write',
|
'apitoken.scope_write': 'Read & write',
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,10 @@ export default {
|
||||||
'apitoken.title': 'Tokens de API',
|
'apitoken.title': 'Tokens de API',
|
||||||
'apitoken.desc': 'Tokens de acceso personal para la API pública, limitados a este espacio de trabajo. Trátalos como contraseñas: cualquiera que tenga el token puede actuar como tú aquí.',
|
'apitoken.desc': 'Tokens de acceso personal para la API pública, limitados a este espacio de trabajo. Trátalos como contraseñas: cualquiera que tenga el token puede actuar como tú aquí.',
|
||||||
'apitoken.docs_link': '¿Nuevo en la API? Consulta la documentación completa →',
|
'apitoken.docs_link': '¿Nuevo en la API? Consulta la documentación completa →',
|
||||||
|
'apitoken.portal_url_label': 'URL del portal de agencia',
|
||||||
|
'apitoken.invite_label': 'Invitación para copiar — envíala a la agencia:',
|
||||||
|
'apitoken.invite_text': 'Ve a {url} y pega esta clave de acceso: {key}',
|
||||||
|
'apitoken.copy_invite': 'Copiar invitación',
|
||||||
'apitoken.name_placeholder': 'p. ej. Integración de agencia',
|
'apitoken.name_placeholder': 'p. ej. Integración de agencia',
|
||||||
'apitoken.scope_read': 'Solo lectura',
|
'apitoken.scope_read': 'Solo lectura',
|
||||||
'apitoken.scope_write': 'Lectura y escritura',
|
'apitoken.scope_write': 'Lectura y escritura',
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,10 @@ export default {
|
||||||
'apitoken.title': "Jetons d'API",
|
'apitoken.title': "Jetons d'API",
|
||||||
'apitoken.desc': "Jetons d'accès personnels pour l'API publique, limités à cet espace de travail. Traitez-les comme des mots de passe : toute personne disposant du jeton peut agir en votre nom ici.",
|
'apitoken.desc': "Jetons d'accès personnels pour l'API publique, limités à cet espace de travail. Traitez-les comme des mots de passe : toute personne disposant du jeton peut agir en votre nom ici.",
|
||||||
'apitoken.docs_link': "Nouveau sur l'API ? Voir la documentation complète →",
|
'apitoken.docs_link': "Nouveau sur l'API ? Voir la documentation complète →",
|
||||||
|
'apitoken.portal_url_label': 'URL du portail agence',
|
||||||
|
'apitoken.invite_label': "Invitation à copier — envoyez-la à l'agence :",
|
||||||
|
'apitoken.invite_text': "Allez sur {url} et collez cette clé d'accès : {key}",
|
||||||
|
'apitoken.copy_invite': "Copier l'invitation",
|
||||||
'apitoken.name_placeholder': 'p. ex. Intégration agence',
|
'apitoken.name_placeholder': 'p. ex. Intégration agence',
|
||||||
'apitoken.scope_read': 'Lecture seule',
|
'apitoken.scope_read': 'Lecture seule',
|
||||||
'apitoken.scope_write': 'Lecture et écriture',
|
'apitoken.scope_write': 'Lecture et écriture',
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,10 @@ export default {
|
||||||
'apitoken.title': 'Tokens de API',
|
'apitoken.title': 'Tokens de API',
|
||||||
'apitoken.desc': 'Tokens de acesso pessoal para a API pública, restritos a este espaço de trabalho. Trate-os como senhas — qualquer pessoa com o token pode agir como você aqui.',
|
'apitoken.desc': 'Tokens de acesso pessoal para a API pública, restritos a este espaço de trabalho. Trate-os como senhas — qualquer pessoa com o token pode agir como você aqui.',
|
||||||
'apitoken.docs_link': 'Novo na API? Veja a documentação completa →',
|
'apitoken.docs_link': 'Novo na API? Veja a documentação completa →',
|
||||||
|
'apitoken.portal_url_label': 'URL do portal da agência',
|
||||||
|
'apitoken.invite_label': 'Convite para copiar — envie para a agência:',
|
||||||
|
'apitoken.invite_text': 'Acesse {url} e cole esta chave de acesso: {key}',
|
||||||
|
'apitoken.copy_invite': 'Copiar convite',
|
||||||
'apitoken.name_placeholder': 'ex.: Integração da agência',
|
'apitoken.name_placeholder': 'ex.: Integração da agência',
|
||||||
'apitoken.scope_read': 'Somente leitura',
|
'apitoken.scope_read': 'Somente leitura',
|
||||||
'apitoken.scope_write': 'Leitura e escrita',
|
'apitoken.scope_write': 'Leitura e escrita',
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,11 @@ export async function render(container) {
|
||||||
const r = await api.createToken(payload);
|
const r = await api.createToken(payload);
|
||||||
const box = document.getElementById('tokenSecretBox');
|
const box = document.getElementById('tokenSecretBox');
|
||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
|
// #73: for agency tokens, surface the handoff (portal URL + a copyable invite). The key
|
||||||
|
// is in the invite TEXT, never in a URL (Cloudflare logs query strings + chat apps unfurl
|
||||||
|
// links). window.location.origin is the real public host the admin is on (correct behind CF).
|
||||||
|
const portalUrl = window.location.origin + '/agency';
|
||||||
|
const inviteText = t('apitoken.invite_text', { url: portalUrl, key: r.token });
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<div style="background:var(--bg-secondary);border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-bottom:16px">
|
<div style="background:var(--bg-secondary);border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-bottom:16px">
|
||||||
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.secret_title')}</h4>
|
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.secret_title')}</h4>
|
||||||
|
|
@ -480,6 +485,14 @@ export async function render(container) {
|
||||||
<input type="text" class="input" readonly value="${esc(r.token)}" style="font-family:monospace;flex:1" onclick="this.select()">
|
<input type="text" class="input" readonly value="${esc(r.token)}" style="font-family:monospace;flex:1" onclick="this.select()">
|
||||||
<button class="btn btn-secondary btn-sm" id="copyTokenBtn">${t('apitoken.copy')}</button>
|
<button class="btn btn-secondary btn-sm" id="copyTokenBtn">${t('apitoken.copy')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
${scope === 'agency' ? `
|
||||||
|
<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:12px">
|
||||||
|
<label style="font-size:12px;color:var(--text-muted)">${t('apitoken.portal_url_label')}</label>
|
||||||
|
<input type="text" class="input" readonly value="${esc(portalUrl)}" style="width:100%;margin-top:4px" onclick="this.select()">
|
||||||
|
<label style="font-size:12px;color:var(--text-muted);display:block;margin-top:10px">${t('apitoken.invite_label')}</label>
|
||||||
|
<textarea class="input" readonly rows="2" style="width:100%;margin-top:4px;font-size:13px;font-family:inherit" onclick="this.select()">${esc(inviteText)}</textarea>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="copyInviteBtn" style="margin-top:8px">${t('apitoken.copy_invite')}</button>
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('copyTokenBtn')?.addEventListener('click', async () => {
|
document.getElementById('copyTokenBtn')?.addEventListener('click', async () => {
|
||||||
|
|
@ -488,6 +501,12 @@ export async function render(container) {
|
||||||
showToast(t('apitoken.copied'), 'success');
|
showToast(t('apitoken.copied'), 'success');
|
||||||
} catch { /* clipboard may be unavailable; the field is selectable */ }
|
} catch { /* clipboard may be unavailable; the field is selectable */ }
|
||||||
});
|
});
|
||||||
|
document.getElementById('copyInviteBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteText); // full "go here + paste key" text
|
||||||
|
showToast(t('apitoken.copied'), 'success');
|
||||||
|
} catch { /* field is selectable as a fallback */ }
|
||||||
|
});
|
||||||
document.getElementById('tokName').value = '';
|
document.getElementById('tokName').value = '';
|
||||||
showToast(t('apitoken.created_toast'), 'success');
|
showToast(t('apitoken.created_toast'), 'success');
|
||||||
loadTokens();
|
loadTokens();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue