Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions app/components/AccountItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,370 @@
<script setup lang="ts">
import type {
KeytraceAccount,
KeytraceReverifyRequest,
KeytraceReverifyResponse,
KeytraceVerificationStatus,
} from '#shared/types/keytrace'

const props = defineProps<{
identity: string
account: KeytraceAccount
}>()

const platformLabelMap: Record<string, string> = {
github: 'GitHub',
npm: 'npm',
mastodon: 'Mastodon',
discord: 'Discord',
orcid: 'ORCID',
}

const platformIconMap: Record<string, string> = {
github: 'i-simple-icons:github',
npm: 'i-simple-icons:npm',
mastodon: 'i-simple-icons:mastodon',
discord: 'i-simple-icons:discord',
orcid: 'i-simple-icons:orcid',
}

const proofMethodLabelMap: Record<KeytraceAccount['proofMethod'], string> = {
dns: 'DNS',
github: 'GitHub',
npm: 'npm',
mastodon: 'Mastodon',
pgp: 'PGP',
other: 'other',
}

const statusLabelMap: Record<KeytraceAccount['status'], string> = {
verified: 'Verified',
unverified: 'Unverified',
stale: 'Stale',
failed: 'Failed',
}

const statusClassMap: Record<KeytraceAccount['status'], string> = {
verified: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
unverified: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/30',
stale: 'bg-orange-500/15 text-orange-300 border-orange-500/30',
failed: 'bg-red-500/15 text-red-300 border-red-500/30',
}

const platformLabel = computed(() => {
const normalizedPlatform = props.account.platform.toLowerCase()
return platformLabelMap[normalizedPlatform] ?? props.account.platform
})

const platformIconClass = computed(() => {
const normalizedPlatform = props.account.platform.toLowerCase()
return platformIconMap[normalizedPlatform] ?? 'i-lucide:user-round'
})

const accountDisplayName = computed(() => props.account.displayName || props.account.username)
const accountAvatar = computed(() => props.account.avatar)

const localStatus = ref<KeytraceVerificationStatus>(props.account.status)
const localLastCheckedAt = ref(props.account.lastCheckedAt)
const localFailureReason = ref(props.account.failureReason)
const isReverifying = ref(false)
const reverifyError = ref<string | null>(null)
const panelVisible = ref(false)
const currentVerificationStep = ref(-1)
const reverifyTimeoutId = ref<ReturnType<typeof setTimeout> | null>(null)

const verificationSteps = [
'Matching service provider',
'Fetching proof',
'Checking for DID',
'Server verification',
]

function getErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error
}

if (error && typeof error === 'object') {
const maybeError = error as {
message?: unknown
statusMessage?: unknown
data?: { message?: unknown }
}

if (typeof maybeError.data?.message === 'string' && maybeError.data.message.trim()) {
return maybeError.data.message
}

if (typeof maybeError.statusMessage === 'string' && maybeError.statusMessage.trim()) {
return maybeError.statusMessage
}

if (typeof maybeError.message === 'string' && maybeError.message.trim()) {
return maybeError.message
}
}

return 'Unknown error'
}

watch(
() => props.account,
account => {
localStatus.value = account.status
localLastCheckedAt.value = account.lastCheckedAt
localFailureReason.value = account.failureReason
},
{ immediate: true },
)

const statusLabel = computed(() => statusLabelMap[localStatus.value])
const statusClasses = computed(() => statusClassMap[localStatus.value])
const proofMethodLabel = computed(() => proofMethodLabelMap[props.account.proofMethod])

const shouldShowFailureReason = computed(
() =>
!!localFailureReason.value &&
(localStatus.value === 'failed' ||
localStatus.value === 'stale' ||
localStatus.value === 'unverified'),
)

function closeReverifyPanel() {
panelVisible.value = false
}

function cancelReverifyTimeout() {
if (reverifyTimeoutId.value) {
clearTimeout(reverifyTimeoutId.value)
reverifyTimeoutId.value = null
}
}

onUnmounted(() => {
cancelReverifyTimeout()
})

async function reverifyAccount() {
cancelReverifyTimeout()
isReverifying.value = true
reverifyError.value = null
panelVisible.value = true
currentVerificationStep.value = -1

const runStep = async (stepIndex: number) => {
currentVerificationStep.value = stepIndex
await new Promise(resolve => setTimeout(resolve, 220))
}

try {
const body: KeytraceReverifyRequest = {
identity: props.identity,
platform: props.account.platform,
username: props.account.username,
url: props.account.url,
}

const responsePromise = $fetch<KeytraceReverifyResponse>('/api/keytrace/reverify', {
method: 'POST',
body,
})

// Attach rejection handler to prevent unhandled promise rejection warnings
responsePromise.catch(() => {})

await runStep(0)
await runStep(1)
await runStep(2)
await runStep(3)

const response = await responsePromise
Comment thread
coderabbitai[bot] marked this conversation as resolved.

localStatus.value = response.status
localLastCheckedAt.value = response.lastCheckedAt
localFailureReason.value = response.failureReason
currentVerificationStep.value = verificationSteps.length
} catch (error) {
// oxlint-disable-next-line no-console -- log reverify failures for observability
console.error('[keytrace] reverify failed', error)
const errorMessage = getErrorMessage(error)
localFailureReason.value = errorMessage
reverifyError.value = `Re-verification failed: ${errorMessage}`
} finally {
isReverifying.value = false

const closeDelay = reverifyError.value ? 3000 : 1000
reverifyTimeoutId.value = setTimeout(() => {
closeReverifyPanel()
reverifyTimeoutId.value = null
}, closeDelay)
}
}
function getStepState(stepIndex: number): 'done' | 'active' | 'idle' {
if (currentVerificationStep.value > stepIndex) {
return 'done'
}

if (currentVerificationStep.value === stepIndex && isReverifying.value && !reverifyError.value) {
return 'active'
}

if (currentVerificationStep.value >= verificationSteps.length) {
return 'done'
}

return 'idle'
}

function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return 'Unknown date'
}

return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}).format(date)
}
</script>

<template>
<div class="rounded-md border border-border bg-bg-subtle px-3 py-3 sm:px-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-3 min-w-0">
<LinkBase
v-if="account.url"
:to="account.url"
noUnderline
class="inline-flex items-center gap-3 min-w-0 hover:text-accent"
>
<div
class="size-10 rounded-full border border-border overflow-hidden bg-bg-muted shrink-0 flex items-center justify-center"
>
<img
v-if="accountAvatar"
:src="accountAvatar"
:alt="accountDisplayName"
class="w-full h-full object-cover"
/>
<span v-else :class="platformIconClass" class="size-4" aria-hidden="true" />
</div>
<p class="font-mono text-base sm:text-lg font-medium min-w-0 break-words">
{{ accountDisplayName }}
</p>
</LinkBase>

<div v-else class="inline-flex items-center gap-3 min-w-0">
<div
class="size-10 rounded-full border border-border overflow-hidden bg-bg-muted shrink-0 flex items-center justify-center"
>
<img
v-if="accountAvatar"
:src="accountAvatar"
:alt="accountDisplayName"
class="w-full h-full object-cover"
/>
<span v-else :class="platformIconClass" class="size-4" aria-hidden="true" />
</div>
<p class="font-mono text-base sm:text-lg font-medium min-w-0 break-words">
{{ accountDisplayName }}
</p>
</div>

<span
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-mono"
:class="statusClasses"
>
{{ statusLabel }}
</span>
</div>

<p class="mt-2 text-sm text-fg-muted min-w-0 break-words">
via {{ proofMethodLabel }}
<span aria-hidden="true" class="mx-1">&middot;</span>
Added {{ formatDate(account.addedAt) }}
<span aria-hidden="true" class="mx-1">&middot;</span>
Last checked {{ formatDate(localLastCheckedAt) }}
</p>

<p v-if="shouldShowFailureReason" class="mt-2 text-sm text-fg-muted min-w-0 break-words">
{{ localFailureReason }}
</p>
Comment on lines +292 to +302
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Several user-facing strings are hard-coded and not translatable.

via, Added, Last checked (lines 293/295/297), plus Re-verify Claim (line 325), Checking... / Re-verify (line 320), and aria-label: 'Re-verify claim' (line 312) are all English literals. The rest of the component correctly uses t() with statusLabelMap/fallback — these strings should follow the same pattern and be added to the profile.linked_accounts.* i18n namespace.

Also note formatDate is pinned to 'en-US' (line 231), which will produce English month abbreviations even for users on other locales.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/AccountItem.vue` around lines 292 - 302, Several user-facing
strings in AccountItem.vue are hard-coded: the inline strings "via", "Added",
"Last checked", the button/aria texts "Re-verify Claim", "Checking...",
"Re-verify", and the aria-label 'Re-verify claim'; also formatDate is forced to
'en-US'. Replace those literals to use the translation helper (t()) with keys
under profile.linked_accounts.* (follow the pattern used by statusLabelMap) for
proofMethodLabel context and all button/aria labels (e.g., use
t('profile.linked_accounts.via'), t('profile.linked_accounts.added'), etc.), and
ensure the aria-label and button text bind to t() values; finally, stop pinning
formatDate to 'en-US' and instead supply the app/user locale (e.g., use the i18n
locale or a locale prop) so formatDate(account.addedAt) and
formatDate(localLastCheckedAt) render with the user’s locale.

</div>

<div class="flex flex-col items-end gap-2 shrink-0">
<div class="flex items-center gap-2">
<TooltipBase
:is-visible="panelVisible || isReverifying"
position="bottom"
:offset="8"
interactive
:tooltip-attr="{ 'role': 'dialog', 'aria-label': 'Re-verify claim' }"
>
<ButtonBase
size="sm"
:disabled="isReverifying"
:classicon="isReverifying ? 'i-lucide:loader-circle' : 'i-lucide:refresh-cw'"
@click="reverifyAccount"
>
{{ isReverifying ? 'Checking...' : 'Re-verify' }}
</ButtonBase>
Comment on lines +314 to +321
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether ButtonBase supports combining animation utility classes on classicon.
fd -t f 'ButtonBase.vue' -x cat -n {}

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Find ButtonBase component - it may use a different naming convention
fd -i 'button' -t f app/components --extension vue | head -20

Repository: npmx-dev/npmx.dev

Length of output: 209


🏁 Script executed:

# Read AccountItem.vue to examine the code at lines 314-321 and around line 354
wc -l app/components/AccountItem.vue

Repository: npmx-dev/npmx.dev

Length of output: 96


🏁 Script executed:

# Search for ButtonBase usage and definition across the codebase
rg 'ButtonBase' -t vue app/components --max-count 5

Repository: npmx-dev/npmx.dev

Length of output: 89


🏁 Script executed:

# Search for ButtonBase in all files
rg 'ButtonBase' app/components --max-count 10

Repository: npmx-dev/npmx.dev

Length of output: 9022


🏁 Script executed:

# Read AccountItem.vue around lines 314-321 to see the re-verify button
sed -n '310,325p' app/components/AccountItem.vue

Repository: npmx-dev/npmx.dev

Length of output: 691


🏁 Script executed:

# Read AccountItem.vue around line 354 to see the step-list version
sed -n '350,365p' app/components/AccountItem.vue

Repository: npmx-dev/npmx.dev

Length of output: 680


🏁 Script executed:

# Read ButtonBase.vue to understand the classicon prop
cat -n app/components/Button/Base.vue

Repository: npmx-dev/npmx.dev

Length of output: 3135


Add animate-spin to the loader icon for visual feedback during re-verification.

The re-verify button swaps the icon using :classicon="isReverifying ? 'i-lucide:loader-circle' : 'i-lucide:refresh-cw'", but the loader appears static without animation. The step-list implementation (line ~354) demonstrates the pattern by using class="i-lucide:loader-circle size-3 animate-spin". Update the classicon binding to include the animation utility: :classicon="isReverifying ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:refresh-cw'" so the pending state is visually obvious to users.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/AccountItem.vue` around lines 314 - 321, The loader icon on
the re-verify button isn't animated; update the ButtonBase usage in
AccountItem.vue so the classicon binding includes the animation utility when
isReverifying is true (i.e., add animate-spin to the true branch of :classicon).
Locate the ButtonBase component instance that uses the isReverifying prop and
reverifyAccount handler and change the classicon expression to include
"animate-spin" for the loader state so users see a spinning loader during
re-verification.


<template #content>
<div class="w-72 max-w-full p-2 sm:p-3">
<p class="font-mono text-sm font-medium">Re-verify Claim</p>
<p class="text-sm text-fg-subtle mt-1">{{ platformLabel }}</p>

<ul class="mt-3 space-y-2">
<li
v-for="(stepLabel, stepIndex) in verificationSteps"
:key="stepLabel"
class="flex items-center gap-2 text-sm"
:class="{
'text-fg': getStepState(stepIndex) === 'done',
'text-fg-subtle': getStepState(stepIndex) === 'idle',
}"
>
<span
class="size-4 inline-flex items-center justify-center rounded-full border"
:class="{
'border-emerald-400/60 text-emerald-300':
getStepState(stepIndex) === 'done',
'border-accent/70 text-accent': getStepState(stepIndex) === 'active',
'border-border text-fg-subtle': getStepState(stepIndex) === 'idle',
}"
>
<span
v-if="getStepState(stepIndex) === 'done'"
class="i-lucide:check size-3"
aria-hidden="true"
/>
<span
v-else-if="getStepState(stepIndex) === 'active'"
class="i-lucide:loader-circle size-3 animate-spin"
aria-hidden="true"
/>
<span v-else class="size-2 rounded-full bg-current/70" aria-hidden="true" />
</span>
<span>{{ stepLabel }}</span>
</li>
</ul>
</div>
</template>
</TooltipBase>
</div>

<p
v-if="reverifyError"
class="text-sm text-red-300 min-w-0 break-words text-end"
role="alert"
>
{{ reverifyError }}
</p>
</div>
</div>
</div>
</template>
Loading
Loading