Skip to content

Commit a9083f4

Browse files
Your NameCopilot
andcommitted
feat: add Keytrace profile composable and API integration
Co-authored-by: Copilot <copilot@github.com>
1 parent 0941dc2 commit a9083f4

45 files changed

Lines changed: 948 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/components/AccountItem.vue

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
<script setup lang="ts">
2+
import type {
3+
KeytraceAccount,
4+
KeytraceReverifyRequest,
5+
KeytraceReverifyResponse,
6+
KeytraceVerificationStatus,
7+
} from '#shared/types/keytrace'
8+
9+
const props = defineProps<{
10+
account: KeytraceAccount
11+
}>()
12+
13+
const platformLabelMap: Record<string, string> = {
14+
github: 'GitHub',
15+
npm: 'npm',
16+
mastodon: 'Mastodon',
17+
discord: 'Discord',
18+
orcid: 'ORCID',
19+
}
20+
21+
const platformIconMap: Record<string, string> = {
22+
github: 'i-simple-icons:github',
23+
npm: 'i-simple-icons:npm',
24+
mastodon: 'i-simple-icons:mastodon',
25+
discord: 'i-simple-icons:discord',
26+
orcid: 'i-simple-icons:orcid',
27+
}
28+
29+
const proofMethodLabelMap: Record<KeytraceAccount['proofMethod'], string> = {
30+
dns: 'DNS',
31+
github: 'GitHub',
32+
npm: 'npm',
33+
mastodon: 'Mastodon',
34+
pgp: 'PGP',
35+
other: 'other',
36+
}
37+
38+
const statusLabelMap: Record<KeytraceAccount['status'], string> = {
39+
verified: 'Verified',
40+
unverified: 'Unverified',
41+
stale: 'Stale',
42+
failed: 'Failed',
43+
}
44+
45+
const statusClassMap: Record<KeytraceAccount['status'], string> = {
46+
verified: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
47+
unverified: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/30',
48+
stale: 'bg-orange-500/15 text-orange-300 border-orange-500/30',
49+
failed: 'bg-red-500/15 text-red-300 border-red-500/30',
50+
}
51+
52+
const platformLabel = computed(() => {
53+
const normalizedPlatform = props.account.platform.toLowerCase()
54+
return platformLabelMap[normalizedPlatform] ?? props.account.platform
55+
})
56+
57+
const platformIconClass = computed(() => {
58+
const normalizedPlatform = props.account.platform.toLowerCase()
59+
return platformIconMap[normalizedPlatform] ?? 'i-lucide:user-round'
60+
})
61+
62+
const accountDisplayName = computed(() => props.account.displayName || props.account.username)
63+
const accountAvatar = computed(() => props.account.avatar)
64+
65+
const localStatus = ref<KeytraceVerificationStatus>(props.account.status)
66+
const localLastCheckedAt = ref(props.account.lastCheckedAt)
67+
const localFailureReason = ref(props.account.failureReason)
68+
const isReverifying = ref(false)
69+
const reverifyError = ref<string | null>(null)
70+
const panelVisible = ref(false)
71+
const currentVerificationStep = ref(-1)
72+
const reverifyTimeoutId = ref<ReturnType<typeof setTimeout> | null>(null)
73+
74+
const verificationSteps = [
75+
'Matching service provider',
76+
'Fetching proof',
77+
'Checking for DID',
78+
'Server verification',
79+
]
80+
81+
function getErrorMessage(error: unknown): string {
82+
if (typeof error === 'string') {
83+
return error
84+
}
85+
86+
if (error && typeof error === 'object') {
87+
const maybeError = error as {
88+
message?: unknown
89+
statusMessage?: unknown
90+
data?: { message?: unknown }
91+
}
92+
93+
if (typeof maybeError.data?.message === 'string' && maybeError.data.message.trim()) {
94+
return maybeError.data.message
95+
}
96+
97+
if (typeof maybeError.statusMessage === 'string' && maybeError.statusMessage.trim()) {
98+
return maybeError.statusMessage
99+
}
100+
101+
if (typeof maybeError.message === 'string' && maybeError.message.trim()) {
102+
return maybeError.message
103+
}
104+
}
105+
106+
return 'Unknown error'
107+
}
108+
109+
watch(
110+
() => props.account,
111+
account => {
112+
localStatus.value = account.status
113+
localLastCheckedAt.value = account.lastCheckedAt
114+
localFailureReason.value = account.failureReason
115+
},
116+
{ immediate: true },
117+
)
118+
119+
const statusLabel = computed(() => statusLabelMap[localStatus.value])
120+
const statusClasses = computed(() => statusClassMap[localStatus.value])
121+
const proofMethodLabel = computed(() => proofMethodLabelMap[props.account.proofMethod])
122+
123+
const shouldShowFailureReason = computed(
124+
() =>
125+
!!localFailureReason.value &&
126+
(localStatus.value === 'failed' ||
127+
localStatus.value === 'stale' ||
128+
localStatus.value === 'unverified'),
129+
)
130+
131+
function closeReverifyPanel() {
132+
panelVisible.value = false
133+
}
134+
135+
function cancelReverifyTimeout() {
136+
if (reverifyTimeoutId.value) {
137+
clearTimeout(reverifyTimeoutId.value)
138+
reverifyTimeoutId.value = null
139+
}
140+
}
141+
142+
onUnmounted(() => {
143+
cancelReverifyTimeout()
144+
})
145+
146+
async function reverifyAccount() {
147+
cancelReverifyTimeout()
148+
isReverifying.value = true
149+
reverifyError.value = null
150+
panelVisible.value = true
151+
currentVerificationStep.value = -1
152+
153+
const runStep = async (stepIndex: number) => {
154+
currentVerificationStep.value = stepIndex
155+
await new Promise(resolve => setTimeout(resolve, 220))
156+
}
157+
158+
try {
159+
const body: KeytraceReverifyRequest = {
160+
platform: props.account.platform,
161+
username: props.account.username,
162+
url: props.account.url,
163+
}
164+
165+
const responsePromise = $fetch<KeytraceReverifyResponse>('/api/keytrace/reverify', {
166+
method: 'POST',
167+
body,
168+
})
169+
170+
await runStep(0)
171+
await runStep(1)
172+
await runStep(2)
173+
await runStep(3)
174+
175+
const response = await responsePromise
176+
177+
localStatus.value = response.status
178+
localLastCheckedAt.value = response.lastCheckedAt
179+
localFailureReason.value = response.failureReason
180+
currentVerificationStep.value = verificationSteps.length
181+
} catch (error) {
182+
// oxlint-disable-next-line no-console -- log reverify failures for observability
183+
console.error('[keytrace] reverify failed', error)
184+
const errorMessage = getErrorMessage(error)
185+
localFailureReason.value = errorMessage
186+
reverifyError.value = `Re-verification failed: ${errorMessage}`
187+
} finally {
188+
isReverifying.value = false
189+
190+
const closeDelay = reverifyError.value ? 3000 : 1000
191+
reverifyTimeoutId.value = setTimeout(() => {
192+
closeReverifyPanel()
193+
reverifyTimeoutId.value = null
194+
}, closeDelay)
195+
}
196+
}
197+
function getStepState(stepIndex: number): 'done' | 'active' | 'idle' {
198+
if (currentVerificationStep.value > stepIndex) {
199+
return 'done'
200+
}
201+
202+
if (
203+
currentVerificationStep.value === stepIndex &&
204+
(isReverifying.value || !!reverifyError.value)
205+
) {
206+
return 'active'
207+
}
208+
209+
if (currentVerificationStep.value >= verificationSteps.length) {
210+
return 'done'
211+
}
212+
213+
return 'idle'
214+
}
215+
216+
function formatDate(value: string): string {
217+
const date = new Date(value)
218+
if (Number.isNaN(date.getTime())) {
219+
return 'Unknown date'
220+
}
221+
222+
return new Intl.DateTimeFormat('en-US', {
223+
month: 'short',
224+
day: 'numeric',
225+
year: 'numeric',
226+
timeZone: 'UTC',
227+
}).format(date)
228+
}
229+
</script>
230+
231+
<template>
232+
<li class="rounded-md border border-border bg-bg-subtle px-3 py-3 sm:px-4">
233+
<div class="flex items-start justify-between gap-3">
234+
<div class="min-w-0">
235+
<div class="flex items-center gap-3 min-w-0">
236+
<LinkBase
237+
v-if="account.url"
238+
:to="account.url"
239+
noUnderline
240+
class="inline-flex items-center gap-3 min-w-0 hover:text-accent"
241+
>
242+
<div
243+
class="size-10 rounded-full border border-border overflow-hidden bg-bg-muted shrink-0 flex items-center justify-center"
244+
>
245+
<img
246+
v-if="accountAvatar"
247+
:src="accountAvatar"
248+
:alt="accountDisplayName"
249+
class="w-full h-full object-cover"
250+
/>
251+
<span v-else :class="platformIconClass" class="size-4" aria-hidden="true" />
252+
</div>
253+
<p class="font-mono text-base sm:text-lg font-medium min-w-0 break-words">
254+
{{ accountDisplayName }}
255+
</p>
256+
</LinkBase>
257+
258+
<div v-else class="inline-flex items-center gap-3 min-w-0">
259+
<div
260+
class="size-10 rounded-full border border-border overflow-hidden bg-bg-muted shrink-0 flex items-center justify-center"
261+
>
262+
<img
263+
v-if="accountAvatar"
264+
:src="accountAvatar"
265+
:alt="accountDisplayName"
266+
class="w-full h-full object-cover"
267+
/>
268+
<span v-else :class="platformIconClass" class="size-4" aria-hidden="true" />
269+
</div>
270+
<p class="font-mono text-base sm:text-lg font-medium min-w-0 break-words">
271+
{{ accountDisplayName }}
272+
</p>
273+
</div>
274+
275+
<span
276+
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-mono"
277+
:class="statusClasses"
278+
>
279+
{{ statusLabel }}
280+
</span>
281+
</div>
282+
283+
<p class="mt-2 text-sm text-fg-muted min-w-0 break-words">
284+
via {{ proofMethodLabel }}
285+
<span aria-hidden="true" class="mx-1">&middot;</span>
286+
Added {{ formatDate(account.addedAt) }}
287+
<span aria-hidden="true" class="mx-1">&middot;</span>
288+
Last checked {{ formatDate(localLastCheckedAt) }}
289+
</p>
290+
291+
<p v-if="shouldShowFailureReason" class="mt-2 text-sm text-fg-muted min-w-0 break-words">
292+
{{ localFailureReason }}
293+
</p>
294+
</div>
295+
296+
<div class="flex flex-col items-end gap-2 shrink-0">
297+
<div class="flex items-center gap-2">
298+
<TooltipBase
299+
:is-visible="panelVisible || isReverifying"
300+
position="bottom"
301+
:offset="8"
302+
interactive
303+
:tooltip-attr="{ 'role': 'dialog', 'aria-label': 'Re-verify claim' }"
304+
>
305+
<ButtonBase
306+
size="sm"
307+
:disabled="isReverifying"
308+
:classicon="isReverifying ? 'i-lucide:loader-circle' : 'i-lucide:refresh-cw'"
309+
@click="reverifyAccount"
310+
>
311+
{{ isReverifying ? 'Checking...' : 'Re-verify' }}
312+
</ButtonBase>
313+
314+
<template #content>
315+
<div class="w-72 max-w-full p-2 sm:p-3">
316+
<p class="font-mono text-sm font-medium">Re-verify Claim</p>
317+
<p class="text-sm text-fg-subtle mt-1">{{ platformLabel }}</p>
318+
319+
<ul class="mt-3 space-y-2">
320+
<li
321+
v-for="(stepLabel, stepIndex) in verificationSteps"
322+
:key="stepLabel"
323+
class="flex items-center gap-2 text-sm"
324+
:class="{
325+
'text-fg': getStepState(stepIndex) === 'done',
326+
'text-fg-subtle': getStepState(stepIndex) === 'idle',
327+
}"
328+
>
329+
<span
330+
class="size-4 inline-flex items-center justify-center rounded-full border"
331+
:class="{
332+
'border-emerald-400/60 text-emerald-300':
333+
getStepState(stepIndex) === 'done',
334+
'border-accent/70 text-accent': getStepState(stepIndex) === 'active',
335+
'border-border text-fg-subtle': getStepState(stepIndex) === 'idle',
336+
}"
337+
>
338+
<span
339+
v-if="getStepState(stepIndex) === 'done'"
340+
class="i-lucide:check size-3"
341+
aria-hidden="true"
342+
/>
343+
<span
344+
v-else-if="getStepState(stepIndex) === 'active'"
345+
class="i-lucide:loader-circle size-3 animate-spin"
346+
aria-hidden="true"
347+
/>
348+
<span v-else class="size-2 rounded-full bg-current/70" aria-hidden="true" />
349+
</span>
350+
<span>{{ stepLabel }}</span>
351+
</li>
352+
</ul>
353+
</div>
354+
</template>
355+
</TooltipBase>
356+
</div>
357+
358+
<p
359+
v-if="reverifyError"
360+
class="text-sm text-red-300 min-w-0 break-words text-end"
361+
role="alert"
362+
>
363+
{{ reverifyError }}
364+
</p>
365+
</div>
366+
</div>
367+
</li>
368+
</template>

0 commit comments

Comments
 (0)