Skip to content

Commit d0f54fa

Browse files
appleboyclaude
andauthored
feat(ui): add copy-to-clipboard for Client ID and Secret fields (#147)
- Add reusable CopyableValue templ component with inline copy button - Apply to all Client ID displays across admin and user pages - Add copy button for Client Secret on user app creation page - Use event delegation with icon swap feedback on copy - Support default, compact, and wrap size variants - Read copy value from text node to avoid duplicating secrets in DOM - Add responsive breakpoint and accessibility improvements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cd57b6a commit d0f54fa

10 files changed

Lines changed: 224 additions & 13 deletions

internal/templates/admin_client_created.templ

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ templ AdminClientCreated(props ClientCreatedPageProps) {
4444
<div class="admin-detail-section">
4545
<div class="admin-detail-row">
4646
<div class="admin-detail-label">Client ID</div>
47-
<div class="admin-detail-value">{ props.Client.ClientID }</div>
47+
<div class="admin-detail-value">
48+
@CopyableValue(props.Client.ClientID, "Client ID", CopyableValueDefault)
49+
</div>
4850
</div>
4951
<div class="admin-detail-row">
5052
<div class="admin-detail-label">Client Name</div>

internal/templates/admin_client_detail.templ

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ templ AdminClientDetail(props ClientDetailPageProps) {
2525
<div class="admin-detail-section">
2626
<div class="admin-detail-row">
2727
<div class="admin-detail-label">Client ID</div>
28-
<div class="admin-detail-value">{ props.Client.ClientID }</div>
28+
<div class="admin-detail-value">
29+
@CopyableValue(props.Client.ClientID, "Client ID", CopyableValueDefault)
30+
</div>
2931
</div>
3032
<div class="admin-detail-row">
3133
<div class="admin-detail-label">Client Name</div>

internal/templates/admin_clients.templ

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,7 @@ templ ClientTableRow(client services.ClientWithCreator, csrfToken string) {
163163
</div>
164164
</td>
165165
<td data-label="Client ID">
166-
<div class="client-id client-id-box">
167-
{ client.ClientID }
168-
</div>
166+
@CopyableValue(client.ClientID, "Client ID", CopyableValueCompact)
169167
</td>
170168
<td data-label="Grant Types">
171169
<code class="grant-types-code">
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package templates
2+
3+
// CopyableValueSize controls the visual size of the copyable value wrapper.
4+
type CopyableValueSize string
5+
6+
const (
7+
CopyableValueDefault CopyableValueSize = ""
8+
CopyableValueCompact CopyableValueSize = "compact"
9+
CopyableValueWrap CopyableValueSize = "wrap"
10+
)
11+
12+
// CopyableValue renders a monospaced value with an inline copy-to-clipboard button.
13+
templ CopyableValue(value string, label string, size CopyableValueSize) {
14+
<span class={ templ.Classes("copyable-value", templ.KV("copyable-value--compact", size == CopyableValueCompact), templ.KV("copyable-value--wrap", size == CopyableValueWrap)) }>
15+
<code class="copyable-value-text">{ value }</code>
16+
<button
17+
type="button"
18+
class="copyable-value-btn"
19+
aria-label={ "Copy " + label + " to clipboard" }
20+
title={ "Copy " + label }
21+
>
22+
<svg class="copyable-icon copyable-icon-copy" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><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>
23+
<svg class="copyable-icon copyable-icon-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><polyline points="20 6 9 17 4 12"></polyline></svg>
24+
</button>
25+
</span>
26+
}

internal/templates/my_apps.templ

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ templ MyAppTableRow(app models.OAuthApplication) {
101101
</div>
102102
</td>
103103
<td data-label="Client ID">
104-
<div class="client-id client-id-box">{ app.ClientID }</div>
104+
@CopyableValue(app.ClientID, "Client ID", CopyableValueCompact)
105105
</td>
106106
<td data-label="Grant Types">
107107
<code class="grant-types-code">{ app.GrantTypes }</code>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/* ============================================
2+
Copyable Value Component
3+
Inline monospaced value with copy-to-clipboard button
4+
============================================ */
5+
6+
.copyable-value {
7+
display: inline-flex;
8+
align-items: center;
9+
gap: var(--space-1);
10+
font-family: var(--font-mono);
11+
font-size: var(--text-sm);
12+
background: var(--color-bg-secondary);
13+
border: 1px solid var(--color-border);
14+
border-radius: var(--radius-sm);
15+
padding: var(--space-2) var(--space-3);
16+
max-width: 100%;
17+
line-height: 1.4;
18+
}
19+
20+
.copyable-value-text {
21+
font-family: inherit;
22+
font-size: inherit;
23+
color: var(--color-text-secondary);
24+
overflow: hidden;
25+
text-overflow: ellipsis;
26+
white-space: nowrap;
27+
min-width: 0;
28+
user-select: all;
29+
/* Reset browser <code> defaults */
30+
background: none;
31+
border: none;
32+
padding: 0;
33+
margin: 0;
34+
}
35+
36+
.copyable-value-btn {
37+
display: inline-flex;
38+
align-items: center;
39+
justify-content: center;
40+
width: 24px;
41+
height: 24px;
42+
padding: 0;
43+
flex-shrink: 0;
44+
background: transparent;
45+
border: 1px solid transparent;
46+
border-radius: var(--radius-sm);
47+
color: var(--color-text-tertiary);
48+
cursor: pointer;
49+
transition: background-color var(--transition-fast),
50+
border-color var(--transition-fast),
51+
color var(--transition-fast);
52+
}
53+
54+
.copyable-value-btn:hover {
55+
background: var(--color-bg-tertiary);
56+
border-color: var(--color-border);
57+
color: var(--color-text-primary);
58+
}
59+
60+
.copyable-value-btn:focus-visible {
61+
outline: 2px solid var(--color-primary);
62+
outline-offset: 1px;
63+
}
64+
65+
.copyable-value-btn--copied {
66+
color: var(--color-success);
67+
}
68+
69+
.copyable-value-btn--copied:hover {
70+
color: var(--color-success);
71+
}
72+
73+
/* Check icon hidden by default, shown when copied */
74+
.copyable-icon-check {
75+
display: none;
76+
}
77+
78+
.copyable-value-btn--copied .copyable-icon-copy {
79+
display: none;
80+
}
81+
82+
.copyable-value-btn--copied .copyable-icon-check {
83+
display: block;
84+
}
85+
86+
/* Wrap variant for full-length secrets */
87+
.copyable-value--wrap {
88+
align-items: flex-start;
89+
}
90+
91+
.copyable-value--wrap .copyable-value-text {
92+
white-space: normal;
93+
word-break: break-all;
94+
overflow: visible;
95+
text-overflow: clip;
96+
}
97+
98+
/* Compact variant for table rows */
99+
.copyable-value--compact {
100+
padding: var(--space-1) var(--space-2);
101+
font-size: var(--text-xs);
102+
max-width: 220px;
103+
}
104+
105+
.copyable-value--compact .copyable-value-btn {
106+
width: 20px;
107+
height: 20px;
108+
}
109+
110+
.copyable-value--compact .copyable-icon {
111+
width: 12px;
112+
height: 12px;
113+
}
114+
115+
/* Responsive: remove max-width on small screens */
116+
@media (max-width: 768px) {
117+
.copyable-value--compact {
118+
max-width: none;
119+
}
120+
}
121+
122+
/* Dark mode */
123+
[data-theme="dark"] .copyable-value {
124+
background: var(--color-bg-tertiary);
125+
border-color: var(--color-border);
126+
}
127+
128+
[data-theme="dark"] .copyable-value-btn:hover {
129+
background: var(--color-bg-secondary);
130+
}
131+
132+
/* Reduced motion */
133+
@media (prefers-reduced-motion: reduce) {
134+
.copyable-value-btn {
135+
transition: none;
136+
}
137+
}

internal/templates/static/css/main.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@import "./base.css";
22
@import "./components/alerts.css";
33
@import "./components/breadcrumbs.css";
4+
@import "./components/copyable-value.css";
45
@import "./components/dark-mode.css";
56
@import "./components/forms.css";
67
@import "./components/modal.css";

internal/templates/static/js/utils.js

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ function formatRelativeTime(timestamp) {
2323
*/
2424
function copyToClipboard(text) {
2525
if (navigator.clipboard && navigator.clipboard.writeText) {
26-
navigator.clipboard.writeText(text).then(function() {
26+
return navigator.clipboard.writeText(text).then(function() {
2727
showNotification('Copied to clipboard!', 'success');
28+
return true;
2829
}).catch(function(err) {
2930
console.error('Failed to copy:', err);
3031
showNotification('Failed to copy', 'error');
32+
return false;
3133
});
3234
} else {
3335
var textarea = document.createElement('textarea');
@@ -37,15 +39,21 @@ function copyToClipboard(text) {
3739
document.body.appendChild(textarea);
3840
textarea.select();
3941

42+
var success = false;
4043
try {
41-
document.execCommand('copy');
42-
showNotification('Copied to clipboard!', 'success');
44+
success = document.execCommand('copy');
45+
if (success) {
46+
showNotification('Copied to clipboard!', 'success');
47+
} else {
48+
showNotification('Failed to copy', 'error');
49+
}
4350
} catch (err) {
4451
console.error('Failed to copy:', err);
4552
showNotification('Failed to copy', 'error');
4653
}
4754

4855
document.body.removeChild(textarea);
56+
return Promise.resolve(success);
4957
}
5058
}
5159

@@ -356,6 +364,37 @@ function initRelativeTime() {
356364
});
357365
}
358366

367+
/**
368+
* Initialize copyable value buttons (event delegation)
369+
*/
370+
function initCopyableValues() {
371+
document.addEventListener('click', function(e) {
372+
var target = e.target;
373+
if (!target || typeof target.closest !== 'function') return;
374+
375+
var btn = target.closest('.copyable-value-btn');
376+
if (!btn) return;
377+
378+
var wrapper = btn.closest('.copyable-value');
379+
var textEl = wrapper && wrapper.querySelector('.copyable-value-text');
380+
if (!textEl) return;
381+
382+
var value = textEl.textContent;
383+
384+
copyToClipboard(value).then(function(success) {
385+
if (!success) return;
386+
387+
btn.classList.add('copyable-value-btn--copied');
388+
389+
if (btn._copyTimer) clearTimeout(btn._copyTimer);
390+
391+
btn._copyTimer = setTimeout(function() {
392+
btn.classList.remove('copyable-value-btn--copied');
393+
}, 1500);
394+
});
395+
});
396+
}
397+
359398
/**
360399
* Initialize search clear buttons
361400
*/
@@ -398,7 +437,8 @@ export {
398437
toggleFilters,
399438
initCollapsibleFilters,
400439
initRelativeTime,
401-
initSearchClear
440+
initSearchClear,
441+
initCopyableValues
402442
};
403443

404444
/**
@@ -420,6 +460,9 @@ document.addEventListener('DOMContentLoaded', function() {
420460
// Search clear buttons
421461
initSearchClear();
422462

463+
// Copyable value buttons
464+
initCopyableValues();
465+
423466
// Keyboard shortcuts
424467
document.addEventListener('keydown', function(e) {
425468
if (e.key === 'Escape') {

internal/templates/user_app_created.templ

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ templ UserAppCreated(props UserClientCreatedPageProps) {
2121
<div class="admin-detail-row">
2222
<div class="admin-detail-label">Client ID</div>
2323
<div class="admin-detail-value">
24-
<code class="client-id-box">{ props.Client.ClientID }</code>
24+
@CopyableValue(props.Client.ClientID, "Client ID", CopyableValueDefault)
2525
</div>
2626
</div>
2727
if props.PlainSecret != "" {
@@ -32,7 +32,7 @@ templ UserAppCreated(props UserClientCreatedPageProps) {
3232
<p style="margin:0 0 var(--space-2);color:var(--color-danger);font-weight:600;">
3333
⚠️ This secret is shown only once. Copy it now.
3434
</p>
35-
<code class="client-id-box" style="word-break:break-all;">{ props.PlainSecret }</code>
35+
@CopyableValue(props.PlainSecret, "Client Secret", CopyableValueWrap)
3636
</div>
3737
</div>
3838
</div>

internal/templates/user_app_detail.templ

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ templ UserAppDetail(props UserClientDetailPageProps) {
3838
<div class="admin-detail-section">
3939
<div class="admin-detail-row">
4040
<div class="admin-detail-label">Client ID</div>
41-
<div class="admin-detail-value">{ props.Client.ClientID }</div>
41+
<div class="admin-detail-value">
42+
@CopyableValue(props.Client.ClientID, "Client ID", CopyableValueDefault)
43+
</div>
4244
</div>
4345
<div class="admin-detail-row">
4446
<div class="admin-detail-label">App Name</div>

0 commit comments

Comments
 (0)