Skip to content

Commit 7c60234

Browse files
committed
feat(desktop): machine ID copy/link on viewer modal and Settings
Add MachineIdSection with UUID display, Copy UUID, Copy MCP header, and paste-to-link for unlinked profiles. Extend viewer identity with edit mode, hostname prefill, and shared helpers for clipboard snippets. Signed-off-by: Joe Sangiorgio <jsangio1@gmail.com>
1 parent 65a33b7 commit 7c60234

8 files changed

Lines changed: 494 additions & 44 deletions

File tree

apps/desktop/src/components/ViewerIdentity.tsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
66
import { X } from 'lucide-react';
77
import { Card, CardContent } from '@mcpmux/ui';
88
import { MachineProfileEditor } from '@/components/machine-profile-editor';
9+
import { MachineIdSection } from '@/components/machine-id-section.component';
910
import { ServerIcon } from '@/components/ServerIcon';
1011
import { useViewerIdentity } from '@/hooks/use-viewer-identity.hook';
1112
import { NAV_SETTINGS } from '@/lib/navigation';
@@ -19,8 +20,10 @@ export function ViewerIdentityModal() {
1920
const navigate = useNavigate();
2021
const {
2122
name,
23+
machineId,
2224
hints,
2325
showPrompt,
26+
promptMode,
2427
isLoading,
2528
isSaving,
2629
nameDraft,
@@ -34,12 +37,20 @@ export function ViewerIdentityModal() {
3437
saveProfile,
3538
canSaveProfile,
3639
closePrompt,
40+
prefillHostnameHint,
41+
linkMachineIdDraft,
42+
setLinkMachineIdDraft,
43+
isLinking,
44+
linkError,
45+
linkMachineById,
3746
} = useViewerIdentity();
3847

3948
if (isLoading || !showPrompt) {
4049
return null;
4150
}
4251

52+
const isEditMode = promptMode === 'edit';
53+
4354
const handleSave = async () => {
4455
const ok = await saveProfile();
4556
if (!ok && !error) {
@@ -68,17 +79,23 @@ export function ViewerIdentityModal() {
6879
type="button"
6980
onClick={closePrompt}
7081
aria-label={t('common:viewerIdentity.close')}
71-
disabled={!name || isSaving}
82+
disabled={(!isEditMode && !name) || isSaving || isLinking}
7283
className="absolute right-3 top-3 rounded-md p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))] disabled:invisible"
7384
>
7485
<X className="h-4 w-4" />
7586
</button>
7687
<CardContent className="flex flex-col gap-4 px-6 pb-6 pt-8">
7788
<div className="space-y-1.5 text-center">
7889
<h2 className="text-lg font-semibold text-[rgb(var(--foreground))]">
79-
{t('common:viewerIdentity.promptTitle')}
90+
{isEditMode
91+
? t('common:viewerIdentity.promptEditTitle')
92+
: t('common:viewerIdentity.promptTitle')}
8093
</h2>
81-
<p className="text-sm text-[rgb(var(--muted))]">{t('common:viewerIdentity.promptDesc')}</p>
94+
<p className="text-sm text-[rgb(var(--muted))]">
95+
{isEditMode
96+
? t('common:viewerIdentity.promptEditDesc')
97+
: t('common:viewerIdentity.promptDesc')}
98+
</p>
8299
{hints ? <p className="text-xs text-[rgb(var(--muted))]">{hints}</p> : null}
83100
</div>
84101

@@ -89,14 +106,34 @@ export function ViewerIdentityModal() {
89106
onNameDraftChange={setNameDraft}
90107
onIconDraftChange={setIconDraft}
91108
onHostnameDraftChange={setHostnameDraft}
109+
onHostnameFocus={() => {
110+
void prefillHostnameHint();
111+
}}
92112
onSave={() => void handleSave()}
93-
isSaving={isSaving}
94-
saveDisabled={!canSaveProfile}
113+
isSaving={isSaving || isLinking}
114+
saveDisabled={!canSaveProfile || isLinking}
95115
nameLabel={t('settings:machineIdentity.nameLabel')}
96116
iconLabel={t('settings:machineIdentity.iconLabel')}
97117
hostnameLabel={t('settings:machineIdentity.hostnameLabel')}
98-
saveLabel={isSaving ? t('common:viewerIdentity.saving') : t('common:viewerIdentity.save')}
118+
saveLabel={
119+
isSaving
120+
? t('common:viewerIdentity.saving')
121+
: isEditMode
122+
? t('common:viewerIdentity.saveEdit')
123+
: t('common:viewerIdentity.save')
124+
}
125+
testIdPrefix="viewer-identity"
126+
/>
127+
128+
<MachineIdSection
129+
machineId={machineId}
130+
linkMachineIdDraft={linkMachineIdDraft}
131+
onLinkMachineIdDraftChange={setLinkMachineIdDraft}
132+
onLink={linkMachineById}
133+
isLinking={isLinking}
134+
linkError={linkError}
99135
testIdPrefix="viewer-identity"
136+
compact
100137
/>
101138

102139
{errorMessage ? <p className="text-sm text-red-500">{errorMessage}</p> : null}
@@ -106,7 +143,7 @@ export function ViewerIdentityModal() {
106143
<button
107144
type="button"
108145
onClick={handleOpenSettings}
109-
disabled={isSaving}
146+
disabled={isSaving || isLinking}
110147
className="text-[rgb(var(--primary))] underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
111148
>
112149
{t('common:viewerIdentity.settingsLink')}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Machine catalog UUID display, copy actions, and paste-to-link UI.
3+
*/
4+
5+
import { useCallback, useState } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { Check, Copy, Loader2 } from 'lucide-react';
8+
import { Button } from '@mcpmux/ui';
9+
import {
10+
buildMcpMachineHeaderSnippet,
11+
copyTextToClipboard,
12+
isMachineUuid,
13+
} from '@/lib/machine-id.helpers';
14+
15+
export interface MachineIdSectionProps {
16+
machineId: string | null;
17+
linkMachineIdDraft?: string;
18+
onLinkMachineIdDraftChange?: (value: string) => void;
19+
onLink?: (id: string) => Promise<boolean>;
20+
isLinking?: boolean;
21+
linkError?: string | null;
22+
testIdPrefix?: string;
23+
compact?: boolean;
24+
}
25+
26+
type CopiedKind = 'uuid' | 'header' | null;
27+
28+
const COPY_FEEDBACK_MS = 1500;
29+
30+
/**
31+
* Render machine ID with copy actions when linked, or paste-to-link when unlinked.
32+
*/
33+
export function MachineIdSection({
34+
machineId,
35+
linkMachineIdDraft = '',
36+
onLinkMachineIdDraftChange,
37+
onLink,
38+
isLinking = false,
39+
linkError = null,
40+
testIdPrefix = 'machine-id',
41+
compact = false,
42+
}: MachineIdSectionProps) {
43+
const { t } = useTranslation('common');
44+
const [copiedKind, setCopiedKind] = useState<CopiedKind>(null);
45+
const [localLinkError, setLocalLinkError] = useState<string | null>(null);
46+
47+
const normalizedId = machineId?.trim() || null;
48+
const prefix = testIdPrefix;
49+
50+
/**
51+
* Copy text and show brief inline feedback on the triggering button.
52+
*/
53+
const handleCopy = useCallback(async (kind: CopiedKind, text: string) => {
54+
try {
55+
await copyTextToClipboard(text);
56+
setCopiedKind(kind);
57+
setTimeout(() => setCopiedKind(null), COPY_FEEDBACK_MS);
58+
} catch {
59+
/* clipboard may be unavailable */
60+
}
61+
}, []);
62+
63+
/**
64+
* Validate draft and delegate linking to the parent hook.
65+
*/
66+
const handleLink = useCallback(async () => {
67+
const draft = linkMachineIdDraft.trim();
68+
if (!isMachineUuid(draft)) {
69+
setLocalLinkError('invalidId');
70+
return;
71+
}
72+
setLocalLinkError(null);
73+
if (!onLink) {
74+
return;
75+
}
76+
const ok = await onLink(draft);
77+
if (!ok && !linkError) {
78+
setLocalLinkError('linkFailed');
79+
}
80+
}, [linkError, linkMachineIdDraft, onLink]);
81+
82+
const resolvedLinkError =
83+
linkError === 'invalidId'
84+
? t('viewerIdentity.invalidId')
85+
: linkError === 'linkNotFound'
86+
? t('viewerIdentity.linkNotFound')
87+
: linkError === 'linkFailed'
88+
? t('viewerIdentity.linkFailed')
89+
: localLinkError === 'invalidId'
90+
? t('viewerIdentity.invalidId')
91+
: localLinkError === 'linkFailed'
92+
? t('viewerIdentity.linkFailed')
93+
: null;
94+
95+
return (
96+
<div
97+
className={[
98+
'space-y-2 rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))]',
99+
compact ? 'p-3' : 'p-4',
100+
].join(' ')}
101+
data-testid={`${prefix}-section`}
102+
>
103+
<p className="text-xs font-medium text-[rgb(var(--muted))]">{t('viewerIdentity.idLabel')}</p>
104+
105+
{normalizedId ? (
106+
<>
107+
<code
108+
className="block break-all rounded-md border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 font-mono text-xs text-[rgb(var(--foreground))]"
109+
data-testid={`${prefix}-value`}
110+
>
111+
{normalizedId}
112+
</code>
113+
<div className="flex flex-wrap gap-2">
114+
<Button
115+
variant="secondary"
116+
size="sm"
117+
disabled={isLinking}
118+
onClick={() => void handleCopy('uuid', normalizedId)}
119+
data-testid={`${prefix}-copy-uuid`}
120+
>
121+
{copiedKind === 'uuid' ? (
122+
<Check className="mr-2 h-4 w-4" />
123+
) : (
124+
<Copy className="mr-2 h-4 w-4" />
125+
)}
126+
{copiedKind === 'uuid' ? t('viewerIdentity.copied') : t('viewerIdentity.copyUuid')}
127+
</Button>
128+
<Button
129+
variant="secondary"
130+
size="sm"
131+
disabled={isLinking}
132+
onClick={() =>
133+
void handleCopy('header', buildMcpMachineHeaderSnippet(normalizedId))
134+
}
135+
data-testid={`${prefix}-copy-header`}
136+
>
137+
{copiedKind === 'header' ? (
138+
<Check className="mr-2 h-4 w-4" />
139+
) : (
140+
<Copy className="mr-2 h-4 w-4" />
141+
)}
142+
{copiedKind === 'header' ? t('viewerIdentity.copied') : t('viewerIdentity.copyHeader')}
143+
</Button>
144+
</div>
145+
</>
146+
) : (
147+
<>
148+
<p className="text-xs text-[rgb(var(--muted))]" data-testid={`${prefix}-pending-hint`}>
149+
{t('viewerIdentity.idPendingHint')}
150+
</p>
151+
{onLink ? (
152+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
153+
<input
154+
type="text"
155+
value={linkMachineIdDraft}
156+
onChange={(e) => {
157+
setLocalLinkError(null);
158+
onLinkMachineIdDraftChange?.(e.target.value);
159+
}}
160+
placeholder={t('viewerIdentity.linkIdPlaceholder')}
161+
disabled={isLinking}
162+
className="min-w-0 flex-1 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 font-mono text-xs"
163+
data-testid={`${prefix}-link-input`}
164+
/>
165+
<Button
166+
variant="secondary"
167+
size="sm"
168+
disabled={isLinking || !linkMachineIdDraft.trim()}
169+
onClick={() => void handleLink()}
170+
data-testid={`${prefix}-link-btn`}
171+
>
172+
{isLinking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
173+
{t('viewerIdentity.linkButton')}
174+
</Button>
175+
</div>
176+
) : null}
177+
</>
178+
)}
179+
180+
{resolvedLinkError ? (
181+
<p className="text-xs text-red-500" data-testid={`${prefix}-link-error`}>
182+
{resolvedLinkError}
183+
</p>
184+
) : null}
185+
</div>
186+
);
187+
}

apps/desktop/src/components/machine-profile-editor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface MachineProfileEditorProps {
1313
onNameDraftChange: (value: string) => void;
1414
onIconDraftChange: (value: string) => void;
1515
onHostnameDraftChange: (value: string) => void;
16+
onHostnameFocus?: () => void;
1617
onSave: () => void;
1718
isSaving: boolean;
1819
saveDisabled: boolean;
@@ -33,6 +34,7 @@ export function MachineProfileEditor({
3334
onNameDraftChange,
3435
onIconDraftChange,
3536
onHostnameDraftChange,
37+
onHostnameFocus,
3638
onSave,
3739
isSaving,
3840
saveDisabled,
@@ -76,6 +78,7 @@ export function MachineProfileEditor({
7678
type="text"
7779
value={hostnameDraft}
7880
onChange={(e) => onHostnameDraftChange(e.target.value)}
81+
onFocus={onHostnameFocus}
7982
disabled={isSaving}
8083
className="mt-1 w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3 py-1.5 font-mono text-sm"
8184
data-testid={`${prefix}-hostname`}

0 commit comments

Comments
 (0)