Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
45e8b97
feat: add Keytrace profile composable and API integration
invalid-email-address Apr 23, 2026
5043d74
fix: refactor code by formatting the components and types
invalid-email-address Apr 23, 2026
d44a199
test: fix unit test
invalid-email-address Apr 23, 2026
67f45f3
refactor: fix failed components test, remove error translations from …
invalid-email-address Apr 23, 2026
e72233a
Update i18n schema after removing unused common.error key
invalid-email-address Apr 23, 2026
1e2c977
Update i18n/locales/de-AT.json
BittuBarnwal7479 Apr 23, 2026
eaf0536
fix: failed e2e test
invalid-email-address Apr 23, 2026
ad9e428
fix: failed test
invalid-email-address Apr 23, 2026
bb5b2e9
feat(keytrace): integrate real claims + reverify flow
invalid-email-address Apr 23, 2026
0fbd549
refactor: standardize code formatting and improve readability across …
invalid-email-address Apr 23, 2026
5edfbe7
fix: type check error
invalid-email-address Apr 24, 2026
3978f75
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 24, 2026
14f92a3
fix: implement coderabbit suggestions
invalid-email-address Apr 24, 2026
d3825f4
feat: fix formatting issue
invalid-email-address Apr 24, 2026
c7717a5
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 24, 2026
333db1e
fix: remove this explanation and undo FacetSelector component changes
invalid-email-address Apr 26, 2026
4af28ee
fix: format issue
invalid-email-address Apr 26, 2026
da82dca
bug: removed unuse translation
invalid-email-address Apr 26, 2026
4ceeaa5
refactor: remove ProfileHeader component and simplify profile loading…
invalid-email-address Apr 26, 2026
1c96b93
refactor: simplify avatar URL building and improve function formatting
invalid-email-address Apr 26, 2026
45496f3
refactor: remove unused ProfileHeader component and related accessibi…
invalid-email-address Apr 26, 2026
355c600
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 26, 2026
aa3bd18
refactor: remove unused avatar_alt and unknown_profile properties fro…
invalid-email-address Apr 26, 2026
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
377 changes: 377 additions & 0 deletions app/components/AccountItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,377 @@
<script setup lang="ts">
import type {
KeytraceAccount,
KeytraceReverifyRequest,
KeytraceReverifyResponse,
KeytraceVerificationStatus,
} from '#shared/types/keytrace'

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

const { t } = useI18n()

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'], { key: string; fallback: string }> = {
verified: { key: 'profile.linked_accounts.status.verified', fallback: 'Verified' },
unverified: { key: 'profile.linked_accounts.status.unverified', fallback: 'Unverified' },
stale: { key: 'profile.linked_accounts.status.stale', fallback: 'Stale' },
failed: { key: 'profile.linked_accounts.status.failed', fallback: '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(() => {
const statusEntry = statusLabelMap[localStatus.value]
const translatedStatus = t(statusEntry.key)

return translatedStatus === statusEntry.key ? statusEntry.fallback : translatedStatus
})
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