diff --git a/app/components/AccountItem.vue b/app/components/AccountItem.vue new file mode 100644 index 0000000000..fa52c82351 --- /dev/null +++ b/app/components/AccountItem.vue @@ -0,0 +1,377 @@ + + + diff --git a/app/components/LinkedAccounts.vue b/app/components/LinkedAccounts.vue new file mode 100644 index 0000000000..6bc36c035e --- /dev/null +++ b/app/components/LinkedAccounts.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/composables/useKeytraceProfile.ts b/app/composables/useKeytraceProfile.ts new file mode 100644 index 0000000000..3235040027 --- /dev/null +++ b/app/composables/useKeytraceProfile.ts @@ -0,0 +1,54 @@ +import type { KeytraceAccount, KeytraceResponse } from '#shared/types/keytrace' + +const statusPriority: Record = { + verified: 0, + unverified: 1, + stale: 2, + failed: 3, +} + +export function useKeytraceProfile(domain: MaybeRefOrGetter) { + const asyncData = useFetch( + () => `/api/keytrace/${encodeURIComponent(toValue(domain))}`, + { + default: () => ({ + profile: { + name: '', + avatar: '', + description: '', + }, + accounts: [], + }), + }, + ) + + const profile = computed(() => asyncData.data.value?.profile) + const accounts = computed(() => asyncData.data.value?.accounts ?? []) + + const sortedAccounts = computed(() => + [...accounts.value].sort((a, b) => { + const statusSort = statusPriority[a.status] - statusPriority[b.status] + if (statusSort !== 0) { + return statusSort + } + + return a.platform.localeCompare(b.platform) + }), + ) + + const verifiedAccounts = computed(() => + sortedAccounts.value.filter(account => account.status === 'verified'), + ) + + const nonVerifiedAccounts = computed(() => + sortedAccounts.value.filter(account => account.status !== 'verified'), + ) + + return { + profile, + accounts: sortedAccounts, + verifiedAccounts, + nonVerifiedAccounts, + loading: asyncData.pending, + } +} diff --git a/app/pages/profile/[identity]/index.vue b/app/pages/profile/[identity]/index.vue index 61ca0f69eb..043d6cde62 100644 --- a/app/pages/profile/[identity]/index.vue +++ b/app/pages/profile/[identity]/index.vue @@ -97,6 +97,8 @@ const inviteUrl = computed(() => { }) const safeProfileWebsiteUrl = computed(() => getSafeHttpUrl(profile.value.website)) +const { accounts: keytraceAccounts, loading: keytraceLoading } = useKeytraceProfile(identity) + useCommandPaletteContextCommands( computed((): CommandPaletteContextCommandInput[] => { const commands: CommandPaletteContextCommandInput[] = [] @@ -232,20 +234,36 @@ defineOgImage( +
+ +
+
-

- {{ $t('profile.likes') }} - ({{ likes.records?.length ?? 0 }}) -

+
+

+ {{ $t('profile.likes') }} + ({{ likes.records?.length ?? 0 }}) +

+
-
-

{{ $t('common.error') }}

+
+

{{ $t('profile.likes_error') }}

+
+
+

{{ $t('profile.likes_empty') }}

diff --git a/i18n/locales/ar-EG.json b/i18n/locales/ar-EG.json index 18682843b5..b072fd4842 100644 --- a/i18n/locales/ar-EG.json +++ b/i18n/locales/ar-EG.json @@ -82,7 +82,6 @@ "cancel": "إلغاء", "save": "حفظ", "edit": "تعديل", - "error": "خطأ", "view_on": { "gitlab": "عرض على GitLab", "bitbucket": "عرض على Bitbucket", @@ -113,7 +112,9 @@ "message": "انضم إليّ على npmx", "share_button": "دعوة صديق", "compose_text": "جرّب npmx، المتصفح السريع لحزم npm!" - } + }, + "likes_error": "تعذّر تحميل الحزم المُعجَب بها.", + "likes_empty": "لا توجد حزم مُعجَب بها بعد." }, "package": { "size_increase": { diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index 179ed6251f..31bc7749cf 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -155,7 +155,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "تعذر تحميل الحزم المفضلة.", + "likes_empty": "لا توجد حزم مفضلة بعد." }, "package": { "not_found": "لم يتم العثور على الحزمة", diff --git a/i18n/locales/az-AZ.json b/i18n/locales/az-AZ.json index 93c9a8117a..da00b06ba9 100644 --- a/i18n/locales/az-AZ.json +++ b/i18n/locales/az-AZ.json @@ -193,7 +193,6 @@ "cancel": "Ləğv et", "save": "Saxla", "edit": "Redaktə et", - "error": "Xəta", "view_on": { "npm": "npm-də bax", "github": "GitHub-da bax" @@ -214,7 +213,9 @@ "message": "Deyəsən onlar hələ npmx istifadə etmirlər. Onlara bu barədə demək istəyirsiniz?", "share_button": "Bluesky-da paylaş", "compose_text": "Salam {'@'}{handle}! npmx.dev-i yoxlamısan? Bu, npm reyestri üçün sürətli, müasir və açıq mənbəli brauzerdir.\nhttps://npmx.dev" - } + }, + "likes_error": "Bəyənilən paketlər yüklənə bilmədi.", + "likes_empty": "Hələ bəyənilən paket yoxdur." }, "package": { "not_found": "Paket Tapılmadı", diff --git a/i18n/locales/bg-BG.json b/i18n/locales/bg-BG.json index f937b02e1e..467fced6b9 100644 --- a/i18n/locales/bg-BG.json +++ b/i18n/locales/bg-BG.json @@ -156,7 +156,6 @@ "cancel": "Отказ", "save": "Запазване", "edit": "Редактиране", - "error": "Грешка", "view_on": { "npm": "преглед в npm", "github": "Преглед в GitHub" @@ -177,7 +176,9 @@ "message": "Изглежда, че все още не използват npmx. Искате ли да им кажете за него?", "share_button": "Споделете в Bluesky", "compose_text": "Здравей {'@'}{handle}! Проверил ли си npmx.dev? Това е браузър за npm регистъра - бърз, модерен и с отворен код.\\nhttps://npmx.dev" - } + }, + "likes_error": "Неуспешно зареждане на харесани пакети.", + "likes_empty": "Все още няма харесани пакети." }, "package": { "not_found": "Пакетът не е намерен", diff --git a/i18n/locales/bn-IN.json b/i18n/locales/bn-IN.json index 3712252621..33729ed174 100644 --- a/i18n/locales/bn-IN.json +++ b/i18n/locales/bn-IN.json @@ -121,7 +121,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "পছন্দের প্যাকেজগুলি লোড করা যায়নি।", + "likes_empty": "এখনও কোনো পছন্দের প্যাকেজ নেই।" }, "package": { "not_found": "প্যাকেজ পাওয়া যায়নি", diff --git a/i18n/locales/cs-CZ.json b/i18n/locales/cs-CZ.json index b5ffca3a02..91840b95d9 100644 --- a/i18n/locales/cs-CZ.json +++ b/i18n/locales/cs-CZ.json @@ -323,7 +323,6 @@ "cancel": "Zrušit", "save": "Uložit", "edit": "Upravit", - "error": "Chyba", "view_on": { "npm": "Zobrazit na npm", "github": "Zobrazit na GitHubu", @@ -359,7 +358,9 @@ "message": "Zdá se, že ještě nepoužívají npmx. Chcete jim o tom říct?", "share_button": "Sdílet na Bluesky", "compose_text": "Ahoj {'@'}{handle}! Viděl jsi už npmx.dev? Je to prohlížeč pro npm registr, který je rychlý, moderní a open-source.\nhttps://npmx.dev" - } + }, + "likes_error": "Nepodařilo se načíst oblíbené balíčky.", + "likes_empty": "Zatím žádné oblíbené balíčky." }, "package": { "not_found": "Balíček nenalezen", diff --git a/i18n/locales/de-AT.json b/i18n/locales/de-AT.json index 458cf38f23..93a3bd30ce 100644 --- a/i18n/locales/de-AT.json +++ b/i18n/locales/de-AT.json @@ -21,5 +21,23 @@ "instant_search_turn_on": "Schnellsuche aktivieren", "instant_search_turn_off": "Schnellsuche deaktivieren", "instant_search_advisory": "Die Schnellsuche sendet bei jedem Tastendruck eine Anfrage." + }, + "profile": { + "likes_error": "Gelikte Pakete konnten nicht geladen werden.", + "likes_empty": "Noch keine gelikten Pakete.", + "avatar_alt": "Profilavatar", + "unknown_profile": "Unbekanntes Profil", + "linked_accounts": { + "status": { + "verified": "Verifiziert", + "unverified": "Nicht verifiziert", + "stale": "Veraltet", + "failed": "Fehlgeschlagen" + }, + "title": "Verknüpfte Konten", + "verified_summary": "Verifizierte Konten", + "legend_aria_label": "Legende zum Verifizierungsstatus von Konten", + "empty": "Keine verknüpften Konten" + } } } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 0618235d76..cc07f7170e 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -1,3 +1,21 @@ { - "$schema": "../schema.json" + "$schema": "../schema.json", + "profile": { + "likes_error": "Gelikte Pakete konnten nicht geladen werden.", + "likes_empty": "Noch keine gelikten Pakete.", + "avatar_alt": "Profilavatar", + "unknown_profile": "Unbekanntes Profil", + "linked_accounts": { + "status": { + "verified": "Verifiziert", + "unverified": "Nicht verifiziert", + "stale": "Veraltet", + "failed": "Fehlgeschlagen" + }, + "title": "Verknüpfte Konten", + "verified_summary": "Verifizierte Konten", + "legend_aria_label": "Legende zum Verifizierungsstatus von Konten", + "empty": "Keine verknüpften Konten" + } + } } diff --git a/i18n/locales/de.json b/i18n/locales/de.json index e805f32888..087ad3abbf 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -322,7 +322,6 @@ "cancel": "Abbrechen", "save": "Speichern", "edit": "Bearbeiten", - "error": "Fehler", "view_on": { "npm": "Auf npm ansehen", "github": "Auf GitHub ansehen", @@ -350,6 +349,8 @@ "website": "Website", "website_placeholder": "https://beispiel.de", "likes": "Likes", + "likes_error": "Gelikte Pakete konnten nicht geladen werden.", + "likes_empty": "Noch keine gelikten Pakete.", "seo_title": "{handle} - npmx", "seo_description": "npmx-Profil von {handle}", "not_found": "Profil nicht gefunden", @@ -358,6 +359,18 @@ "message": "Es sieht nicht so aus, als ob sie npmx schon benutzen. Möchtest du ihnen davon erzählen?", "share_button": "Auf Bluesky teilen", "compose_text": "Hey {'@'}{handle}! Hast du schon npmx.dev ausprobiert? Es ist ein Browser für die npm Registry, der schnell, modern und Open-Source ist.\nhttps://npmx.dev" + }, + "linked_accounts": { + "status": { + "verified": "Verifiziert", + "unverified": "Nicht verifiziert", + "stale": "Veraltet", + "failed": "Fehlgeschlagen" + }, + "title": "Verknüpfte Konten", + "verified_summary": "Verifizierte Konten", + "legend_aria_label": "Legende zum Verifizierungsstatus von Konten", + "empty": "Keine verknüpften Konten" } }, "package": { @@ -379,6 +392,7 @@ "size": "Installationsgröße um {percent} gestiegen ({size} größer)", "deps": "{count} zusätzliche Abhängigkeiten" }, + "size_decrease": {}, "replacement": { "title": "Du brauchst diese Abhängigkeit vielleicht nicht.", "native": "Dies kann durch {replacement} ersetzt werden, verfügbar seit Node {nodeVersion}.", @@ -556,6 +570,7 @@ "current_tags": "Aktuelle Tags", "no_match_filter": "Keine Versionen entsprechen {filter}" }, + "timeline": {}, "dependencies": { "title": "Abhängigkeit ({count}) | Abhängigkeiten ({count})", "list_label": "Paketabhängigkeiten", @@ -1316,7 +1331,10 @@ "vulnerabilities": { "label": "Sicherheitslücken", "description": "Bekannte Sicherheitsrisiken" - } + }, + "githubStars": {}, + "githubIssues": {}, + "createdAt": {} }, "values": { "any": "Beliebig", diff --git a/i18n/locales/en-GB.json b/i18n/locales/en-GB.json index cd250a7f15..09c69c0e03 100644 --- a/i18n/locales/en-GB.json +++ b/i18n/locales/en-GB.json @@ -45,5 +45,9 @@ "error": "Failed to load organisations", "empty": "No organisations found" } + }, + "profile": { + "likes_error": "Could not load liked packages.", + "likes_empty": "No liked packages yet." } } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9c132582da..bad836caf6 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -327,7 +327,6 @@ "cancel": "Cancel", "save": "Save", "edit": "Edit", - "error": "Error", "view_on": { "npm": "View on npm", "github": "View on GitHub", @@ -355,6 +354,8 @@ "website": "Website", "website_placeholder": "https://example.com", "likes": "Likes", + "likes_error": "Could not load liked packages.", + "likes_empty": "No liked packages yet.", "seo_title": "{handle} - npmx", "seo_description": "npmx profile by {handle}", "not_found": "Profile Not Found", @@ -363,6 +364,18 @@ "message": "It doesn't look like they're using npmx yet. Want to tell them about it?", "share_button": "Share on Bluesky", "compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a browser for the npm registry that's fast, modern, and open-source.\nhttps://npmx.dev" + }, + "linked_accounts": { + "status": { + "verified": "Verified", + "unverified": "Unverified", + "stale": "Stale", + "failed": "Failed" + }, + "title": "Linked Accounts", + "verified_summary": "{verified}/{total} verified", + "legend_aria_label": "Account verification status legend", + "empty": "No linked accounts" } }, "package": { diff --git a/i18n/locales/es-419.json b/i18n/locales/es-419.json index aff49a7df8..fce9151ed6 100644 --- a/i18n/locales/es-419.json +++ b/i18n/locales/es-419.json @@ -43,7 +43,9 @@ "invite": { "message": "Parece que aún no usa npmx. ¿Quieres contarle?", "compose_text": "¡Hola {'@'}{handle}! ¿Has probado ya npmx.dev? Es un navegador para el registro de npm rápido, moderno y de código abierto.\nhttps://npmx.dev" - } + }, + "likes_error": "No se pudieron cargar los paquetes favoritos.", + "likes_empty": "Aún no hay paquetes favoritos." }, "package": { "readme": { diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 9bcb7263fc..1586180d9c 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -211,7 +211,6 @@ "cancel": "Cancelar", "save": "Guardar", "edit": "Editar", - "error": "Error", "view_on": { "npm": "ver en npm", "github": "Ver en GitHub", @@ -244,7 +243,9 @@ "message": "Parece que aún no usa npmx. ¿Quieres contárselo?", "share_button": "Compartir en Bluesky", "compose_text": "¡Hola {'@'}{handle}! ¿Has probado ya npmx.dev? Es un navegador para el registro de npm rápido, moderno y de código abierto.\nhttps://npmx.dev" - } + }, + "likes_error": "No se pudieron cargar los paquetes que te gustan.", + "likes_empty": "Aún no hay paquetes que te gusten." }, "package": { "not_found": "Paquete no encontrado", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 0e40fa415b..448df305b1 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -324,7 +324,6 @@ "cancel": "Annuler", "save": "Enregistrer", "edit": "Modifier", - "error": "Erreur", "view_on": { "npm": "voir sur npm", "github": "Voir sur GitHub", @@ -360,7 +359,9 @@ "message": "Il semblerait qu'ils n'utilisent pas encore npmx. Vous voulez leur en parler ?", "share_button": "Partager sur Bluesky", "compose_text": "Salut {'@'}{handle} ! As-tu déjà testé npmx.dev ? C'est un navigateur pour le registre npm : rapide, moderne et open source.\nhttps://npmx.dev" - } + }, + "likes_error": "Impossible de charger les paquets aimés.", + "likes_empty": "Aucun paquet aimé pour le moment." }, "package": { "not_found": "Paquet introuvable", diff --git a/i18n/locales/hi-IN.json b/i18n/locales/hi-IN.json index ea702a7728..ba58005b1e 100644 --- a/i18n/locales/hi-IN.json +++ b/i18n/locales/hi-IN.json @@ -210,7 +210,6 @@ "cancel": "रद्द करें", "save": "सहेजें", "edit": "संपादित करें", - "error": "त्रुटि", "view_on": { "npm": "npm पर देखें", "github": "GitHub पर देखें", @@ -229,7 +228,9 @@ "expand": "विस्तृत करें" }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "पसंद किए गए पैकेज लोड नहीं हो सके।", + "likes_empty": "अभी तक कोई पसंद किए गए पैकेज नहीं।" }, "package": { "not_found": "पैकेज नहीं मिला", diff --git a/i18n/locales/hu-HU.json b/i18n/locales/hu-HU.json index c14948a6e1..f2a2bb74ab 100644 --- a/i18n/locales/hu-HU.json +++ b/i18n/locales/hu-HU.json @@ -156,7 +156,6 @@ "cancel": "Mégse", "save": "Mentés", "edit": "Szerkesztés", - "error": "Hiba", "view_on": { "npm": "megtekintés npm-en", "github": "Megtekintés GitHubon" @@ -177,7 +176,9 @@ "message": "Úgy tűnik, hogy még nem használja az npmx-et. Szeretnéd megtudatni vele?", "share_button": "Megosztás a Bluesky-on", "compose_text": "Halló {'@'}{handle}! Már próbáltad az npmx.dev-et? Egy gyors, modern és nyílt forráskódú böngésző az npm regiszterhez.\nhttps://npmx.dev" - } + }, + "likes_error": "Nem sikerült betölteni a kedvelt csomagokat.", + "likes_empty": "Még nincsenek kedvelt csomagok." }, "package": { "not_found": "Csomag Nem Található", diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 9646ca17b1..b96137c96c 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -211,7 +211,6 @@ "cancel": "Batal", "save": "Simpan", "edit": "Edit", - "error": "Kesalahan", "view_on": { "npm": "lihat di npm", "github": "Lihat di GitHub", @@ -244,7 +243,9 @@ "message": "Sepertinya mereka belum menggunakan npmx. Ingin memberi tahu mereka?", "share_button": "Bagikan di Bluesky", "compose_text": "Hai {'@'}{handle}! Sudah pernah mencoba npmx.dev? Ini adalah browser untuk npm registry yang cepat, modern, dan open-source.\nhttps://npmx.dev" - } + }, + "likes_error": "Gagal memuat paket yang disukai.", + "likes_empty": "Belum ada paket yang disukai." }, "package": { "not_found": "Paket Tidak Ditemukan", diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index b19efca8db..da169b7504 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -155,7 +155,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "Impossibile caricare i pacchetti preferiti.", + "likes_empty": "Nessun pacchetto preferito ancora." }, "package": { "not_found": "Pacchetto Non Trovato", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 72397e4907..79f498916c 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -193,7 +193,6 @@ "cancel": "キャンセル", "save": "保存", "edit": "編集", - "error": "エラー", "view_on": { "npm": "npmで表示", "github": "GitHubで表示", @@ -224,7 +223,9 @@ "message": "まだnpmxを利用していないようです。npmxを紹介しますか?", "share_button": "Blueskyで共有", "compose_text": "{'@'}{handle} さん、npmx.devはもうチェックしましたか? 高速でモダンなオープンソースのnpmレジストリブラウザです。\nhttps://npmx.dev" - } + }, + "likes_error": "いいねしたパッケージを読み込めませんでした。", + "likes_empty": "いいねしたパッケージはまだありません。" }, "package": { "not_found": "パッケージが見つかりません", diff --git a/i18n/locales/kn-IN.json b/i18n/locales/kn-IN.json index 2111c4dd23..084628b057 100644 --- a/i18n/locales/kn-IN.json +++ b/i18n/locales/kn-IN.json @@ -122,7 +122,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "ಇಷ್ಟಪಟ್ಟ ಪ್ಯಾಕೇಜುಗಳನ್ನು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ.", + "likes_empty": "ಇನ್ನೂ ಇಷ್ಟಪಟ್ಟ ಪ್ಯಾಕೇಜುಗಳಿಲ್ಲ." }, "package": { "not_found": "ಪ್ಯಾಕೇಜ್ ಕಂಡುಬಂದಿಲ್ಲ", diff --git a/i18n/locales/mr-IN.json b/i18n/locales/mr-IN.json index 57c36430e0..3f7a802b7d 100644 --- a/i18n/locales/mr-IN.json +++ b/i18n/locales/mr-IN.json @@ -194,7 +194,6 @@ "cancel": "रद्द करा", "save": "जतन करा", "edit": "संपादित करा", - "error": "त्रुटी", "view_on": { "npm": "npm वर पहा", "github": "GitHub वर पहा", @@ -213,7 +212,9 @@ "expand": "विस्तृत करा" }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "आवडलेल्या पॅकेजेस लोड करता आल्या नाहीत.", + "likes_empty": "अद्याप आवडलेली कोणतीही पॅकेजेस नाहीत." }, "package": { "not_found": "पॅकेज सापडले नाही", diff --git a/i18n/locales/nb-NO.json b/i18n/locales/nb-NO.json index 1d6351607a..79f07386ba 100644 --- a/i18n/locales/nb-NO.json +++ b/i18n/locales/nb-NO.json @@ -322,7 +322,6 @@ "cancel": "Avbryt", "save": "Lagre", "edit": "Rediger", - "error": "Feil", "view_on": { "npm": "vis på npm", "github": "Vis på GitHub", @@ -358,7 +357,9 @@ "message": "Det ser ikke ut som de bruker npmx ennå. Vil du fortelle dem om det?", "share_button": "Del på Bluesky", "compose_text": "Hei {'@'}{handle}! Har du sjekket ut npmx.dev ennå? Det er en leser for npm-registeret som er rask, moderne og åpen kildekode.\nhttps://npmx.dev" - } + }, + "likes_error": "Kunne ikke laste likte pakker.", + "likes_empty": "Ingen likte pakker ennå." }, "package": { "not_found": "Pakke ikke funnet", diff --git a/i18n/locales/ne-NP.json b/i18n/locales/ne-NP.json index 60b7dec1f1..d76a7e7235 100644 --- a/i18n/locales/ne-NP.json +++ b/i18n/locales/ne-NP.json @@ -122,7 +122,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "मनपरेका प्याकेजहरू लोड गर्न सकिएन।", + "likes_empty": "अहिले सम्म मनपरेका प्याकेजहरू छैनन्।" }, "package": { "not_found": "प्याकेज फेला परेन", diff --git a/i18n/locales/nl.json b/i18n/locales/nl.json index 85e5f13c1a..dc88b57c84 100644 --- a/i18n/locales/nl.json +++ b/i18n/locales/nl.json @@ -322,7 +322,6 @@ "cancel": "Annuleer", "save": "Opslaan", "edit": "Wijzigen", - "error": "Fout", "view_on": { "npm": "Bekijk op npm", "github": "Bekijk op GitHub", @@ -358,7 +357,9 @@ "message": "Het lijkt erop dat ze npmx nog niet gebruiken. Wilt u ze hierop wijzen", "share_button": "Deel op Bluesky", "compose_text": "Hallo {'@'}{handle}! Hebt u npmx.dev al bekeken? Het is een browser voor het npm register die snel, modern en open source is.\nhttps://npmx.dev" - } + }, + "likes_error": "Kon gelikete pakketten niet laden.", + "likes_empty": "Nog geen gelikete pakketten." }, "package": { "not_found": "Pakket niet gevonden", diff --git a/i18n/locales/pl-PL.json b/i18n/locales/pl-PL.json index f4acd249ed..f031246b0e 100644 --- a/i18n/locales/pl-PL.json +++ b/i18n/locales/pl-PL.json @@ -193,7 +193,6 @@ "cancel": "Anuluj", "save": "Zapisz", "edit": "Edytuj", - "error": "Błąd", "view_on": { "npm": "zobacz na npm", "github": "Zobacz w GitHub", @@ -224,7 +223,9 @@ "message": "Wygląda na to, że jeszcze nie korzystają z npmx. Chcesz ich powiadomić?", "share_button": "Podziel się na Bluesky", "compose_text": "Hej {'@'}{handle}! Czy znasz już npmx.dev? To szybka, nowoczesna przeglądarka rejestru npm z otwartym kodem źródłowym.\nhttps://npmx.dev" - } + }, + "likes_error": "Nie udało się wczytać polubionych pakietów.", + "likes_empty": "Brak polubionych pakietów." }, "package": { "not_found": "Nie znaleziono pakietu", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 935b61c5ec..419d408722 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -319,7 +319,6 @@ "cancel": "Cancelar", "save": "Salvar", "edit": "Editar", - "error": "Erro", "view_on": { "npm": "Ver no npm", "github": "Ver no GitHub", @@ -355,7 +354,9 @@ "message": "Parece que eles ainda não estão usando o npmx. Quer contar a eles sobre isso?", "share_button": "Compartilhar no Bluesky", "compose_text": "Olá, {'@'}{handle}! Você já conferiu npmx.dev? É um navegador para o registro npm rápido, moderno e de código aberto.\nhttps://npmx.dev" - } + }, + "likes_error": "Não foi possível carregar os pacotes curtidos.", + "likes_empty": "Nenhum pacote curtido ainda." }, "package": { "not_found": "Pacote não encontrado", diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index 66dc858a5a..4a9776d06c 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -324,7 +324,6 @@ "cancel": "Отменить", "save": "Сохранить", "edit": "Изменить", - "error": "Ошибка", "view_on": { "npm": "Открыть на npm", "github": "Открыть на GitHub", @@ -360,7 +359,9 @@ "message": "Похоже, этот пользователь ещё не пользуется npmx. Хотите рассказать ему о проекте?", "share_button": "Поделиться в Bluesky", "compose_text": "Привет, {'@'}{handle}! Уже смотрел npmx.dev? Это быстрый, современный и open-source браузер для реестра npm.\nhttps://npmx.dev" - } + }, + "likes_error": "Не удалось загрузить понравившиеся пакеты.", + "likes_empty": "Понравившихся пакетов пока нет." }, "package": { "not_found": "Пакет не найден", diff --git a/i18n/locales/sr-Latn-RS.json b/i18n/locales/sr-Latn-RS.json index c015089b8c..1460ee31af 100644 --- a/i18n/locales/sr-Latn-RS.json +++ b/i18n/locales/sr-Latn-RS.json @@ -211,7 +211,6 @@ "cancel": "Otkažite", "save": "Sačuvajte", "edit": "Uredite", - "error": "Greška", "view_on": { "npm": "pogledajte na npm-u", "github": "Pogledajte na GitHub-u", @@ -244,7 +243,9 @@ "message": "Izgleda da još uvek ne koriste npmx. Želite li da im kažete nešto više o tome?", "share_button": "Podelite na Bluesky-u", "compose_text": "Hej {'@'}{handle}! Da li ste već pogledali npmx.dev? To je pretraživač za npm registar koji je brz, moderan i otvorenog koda.\nhttps://npmx.dev" - } + }, + "likes_error": "Nije moguće učitati omiljene pakete.", + "likes_empty": "Još nema omiljenih paketa." }, "package": { "not_found": "Paket nije pronađen", diff --git a/i18n/locales/ta-IN.json b/i18n/locales/ta-IN.json index e8b657110c..fd5231ece7 100644 --- a/i18n/locales/ta-IN.json +++ b/i18n/locales/ta-IN.json @@ -154,7 +154,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "விரும்பிய தொகுப்புகளை ஏற்ற முடியவில்லை.", + "likes_empty": "இன்னும் விரும்பிய தொகுப்புகள் இல்லை." }, "package": { "not_found": "தொகுப்பு கிடைக்கவில்லை", diff --git a/i18n/locales/te-IN.json b/i18n/locales/te-IN.json index 7e92347d2e..5686a70a35 100644 --- a/i18n/locales/te-IN.json +++ b/i18n/locales/te-IN.json @@ -122,7 +122,9 @@ } }, "profile": { - "invite": {} + "invite": {}, + "likes_error": "Could not load liked packages.", + "likes_empty": "No liked packages yet." }, "package": { "not_found": "ప్యాకేజ్ కనుగొనబడలేదు", diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 963fed0027..630f8b6e31 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -193,7 +193,6 @@ "cancel": "İptal", "save": "Kaydet", "edit": "Düzenle", - "error": "Hata", "view_on": { "npm": "npm'de görüntüle", "github": "GitHub'da görüntüle" @@ -214,7 +213,9 @@ "message": "npmx'i deneyin - npm için daha iyi bir paket tarayıcısı", "share_button": "Paylaş", "compose_text": "npmx'i deneyin - npm için daha iyi bir paket tarayıcısı: {url}" - } + }, + "likes_error": "Beğenilen paketler yüklenemedi.", + "likes_empty": "Henüz beğenilen paket yok." }, "package": { "not_found": "Paket Bulunamadı", diff --git a/i18n/locales/uk-UA.json b/i18n/locales/uk-UA.json index 336c409e1b..1cc61049bb 100644 --- a/i18n/locales/uk-UA.json +++ b/i18n/locales/uk-UA.json @@ -211,7 +211,6 @@ "cancel": "Скасувати", "save": "Зберегти", "edit": "Редагувати", - "error": "Помилка", "view_on": { "npm": "Переглянути на npm", "github": "Переглянути на GitHub", @@ -244,7 +243,9 @@ "message": "Схоже, вони ще не користуються npmx. Хочете розповісти їм про нього?", "share_button": "Поділитися в Bluesky", "compose_text": "Привіт, {'@'}{handle}! Ти вже перевірив npmx.dev? Це швидкий сучасний браузер для реєстру npm з відкритим кодом.\nhttps://npmx.dev" - } + }, + "likes_error": "Не вдалося завантажити вподобані пакети.", + "likes_empty": "Вподобаних пакетів поки немає." }, "package": { "not_found": "Пакет не знайдено", diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index d9a5698fa2..461d32fb43 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -211,7 +211,6 @@ "cancel": "Hủy", "save": "Lưu", "edit": "Chỉnh sửa", - "error": "Lỗi", "view_on": { "npm": "xem trên npm", "github": "Xem trên GitHub", @@ -244,7 +243,9 @@ "message": "Có vẻ họ chưa dùng npmx. Bạn có muốn giới thiệu cho họ không?", "share_button": "Chia sẻ trên Bluesky", "compose_text": "Chào {'@'}{handle}! Bạn đã thử npmx.dev chưa? Đây là trình duyệt cho npm registry, nhanh, hiện đại và mã nguồn mở.\nhttps://npmx.dev" - } + }, + "likes_error": "Không thể tải các package đã thích.", + "likes_empty": "Chưa có package nào được thích." }, "package": { "not_found": "Không tìm thấy gói", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 559283c623..83394d1486 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -323,7 +323,6 @@ "cancel": "取消", "save": "保存", "edit": "编辑", - "error": "加载出错", "view_on": { "npm": "在 npm 上查看", "github": "在 GitHub 上查看", @@ -359,7 +358,9 @@ "message": "看起来他们还没有使用 npmx,去邀请一下?", "share_button": "分享到 Bluesky", "compose_text": "嗨 {'@'}{handle}!您用过 npmx.dev 吗?它是一个快速、现代且开源的 npm registry 浏览器。\nhttps://npmx.dev" - } + }, + "likes_error": "无法加载喜欢的包。", + "likes_empty": "还没有喜欢的包。" }, "package": { "not_found": "未找到包", diff --git a/i18n/locales/zh-TW.json b/i18n/locales/zh-TW.json index 623e3955fe..9ed82a60d4 100644 --- a/i18n/locales/zh-TW.json +++ b/i18n/locales/zh-TW.json @@ -322,7 +322,6 @@ "cancel": "取消", "save": "儲存", "edit": "編輯", - "error": "錯誤", "view_on": { "npm": "在 npm 上檢視", "github": "在 GitHub 上檢視", @@ -357,7 +356,9 @@ "message": "看起來對方還沒在用 npmx。要不要跟他們分享一下?", "share_button": "分享到 Bluesky", "compose_text": "Hey {'@'}{handle}!你用過 npmx.dev 了嗎?它是 npm Registry 的瀏覽器,快速、現代,而且是開源的。\nhttps://npmx.dev" - } + }, + "likes_error": "無法載入喜歡的套件。", + "likes_empty": "目前尚無喜歡的套件。" }, "package": { "not_found": "找不到套件", diff --git a/i18n/schema.json b/i18n/schema.json index f06f2f53a8..2bcf4101d2 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -985,9 +985,6 @@ "edit": { "type": "string" }, - "error": { - "type": "string" - }, "view_on": { "type": "object", "properties": { @@ -1069,6 +1066,12 @@ "likes": { "type": "string" }, + "likes_error": { + "type": "string" + }, + "likes_empty": { + "type": "string" + }, "seo_title": { "type": "string" }, @@ -1095,6 +1098,42 @@ } }, "additionalProperties": false + }, + "linked_accounts": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "verified": { + "type": "string" + }, + "unverified": { + "type": "string" + }, + "stale": { + "type": "string" + }, + "failed": { + "type": "string" + } + }, + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "verified_summary": { + "type": "string" + }, + "legend_aria_label": { + "type": "string" + }, + "empty": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/package.json b/package.json index a69d2bd921..019d5f6c3f 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@iconify-json/svg-spinners": "1.2.4", "@iconify-json/vscode-icons": "1.2.45", "@intlify/shared": "11.3.0", + "@keytrace/claims": "1.4.0", "@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935", "@napi-rs/canvas": "0.1.97", "@nuxt/a11y": "1.0.0-alpha.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 673b6bcedb..17c743729e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@intlify/shared': specifier: 11.3.0 version: 11.3.0 + '@keytrace/claims': + specifier: 1.4.0 + version: 1.4.0 '@lunariajs/core': specifier: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935 version: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935 @@ -2091,6 +2094,10 @@ packages: '@jsr/std__path@1.1.4': resolution: {integrity: sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==, tarball: https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz} + '@keytrace/claims@1.4.0': + resolution: {integrity: sha512-XeojwXFFnvhPsDI7f99e2KCvk8gDC5ztKxRBplB+Zx5OmfcaBovX+DZJODZsxz3b1r4ZITBDOjZD8gftgpAZyw==} + engines: {node: '>=18'} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -13325,6 +13332,8 @@ snapshots: dependencies: '@jsr/std__internal': 1.0.12 + '@keytrace/claims@1.4.0': {} + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 diff --git a/server/api/keytrace/[domain].ts b/server/api/keytrace/[domain].ts new file mode 100644 index 0000000000..3fe34ded03 --- /dev/null +++ b/server/api/keytrace/[domain].ts @@ -0,0 +1,177 @@ +import { getClaimsForHandle, type ClaimVerificationResult } from '@keytrace/claims' +import type { KeytraceProofMethod, KeytraceResponse } from '#shared/types/keytrace' +import { + getOptionalClaimField, + mapKeytraceVerificationStatus, + mapClaimTypeToPlatform, +} from '#server/utils/keytrace' +import { toProxiedImageUrl } from '#server/utils/image-proxy' +import { getSafeHttpUrl } from '#shared/utils/url' + +function domainToDisplayName(domain: string): string { + const firstSegment = domain.split('.')[0] || domain + return firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1) +} + +function buildDefaultAvatarUrl(domain: string, imageProxySecret: string): string { + return buildSeededAvatarUrl(domain, imageProxySecret) +} + +function buildSeededAvatarUrl(seed: string, imageProxySecret: string): string { + const rawFallbackAvatar = `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(seed)}` + return toProxiedImageUrl(rawFallbackAvatar, imageProxySecret) +} + +function toProfileAvatarUrl( + rawAvatarUrl: string | undefined, + domain: string, + imageProxySecret: string, +): string { + const safeAvatarUrl = getSafeHttpUrl(rawAvatarUrl) + if (!safeAvatarUrl) { + return buildDefaultAvatarUrl(domain, imageProxySecret) + } + + return toProxiedImageUrl(safeAvatarUrl, imageProxySecret) +} + +function toAccountAvatarUrl( + rawAvatarUrl: string | undefined, + imageProxySecret: string, + fallbackSeed: string, +): string { + const safeAvatarUrl = getSafeHttpUrl(rawAvatarUrl) + if (!safeAvatarUrl) { + return buildSeededAvatarUrl(fallbackSeed, imageProxySecret) + } + + return toProxiedImageUrl(safeAvatarUrl, imageProxySecret) +} + +function buildFallbackProfile(domain: string, imageProxySecret: string): KeytraceResponse { + return { + profile: { + name: `${domainToDisplayName(domain)} Developer`, + avatar: buildDefaultAvatarUrl(domain, imageProxySecret), + description: `No Keytrace claims found for ${domain}.`, + }, + accounts: [], + } +} + +function buildServiceUnavailableProfile( + domain: string, + imageProxySecret: string, +): KeytraceResponse { + return { + profile: { + name: `${domainToDisplayName(domain)} Developer`, + avatar: buildDefaultAvatarUrl(domain, imageProxySecret), + description: 'Keytrace is temporarily unavailable. Please try again shortly.', + }, + accounts: [], + } +} + +const allowedProofMethods = new Set([ + 'dns', + 'github', + 'npm', + 'mastodon', + 'pgp', + 'other', +]) + +function mapProofMethod(type: string): KeytraceProofMethod { + const mappedType = mapClaimTypeToPlatform(type) + return allowedProofMethods.has(mappedType as KeytraceProofMethod) + ? (mappedType as KeytraceProofMethod) + : 'other' +} + +// Convert Keytrace claims to our account format +function mapClaimsToAccounts( + claims: ClaimVerificationResult[], + imageProxySecret: string, +): KeytraceResponse['accounts'] { + return claims.map(claim => ({ + platform: mapClaimTypeToPlatform(claim.type), + username: claim.identity.subject, + displayName: claim.identity.displayName || claim.identity.subject, + avatar: toAccountAvatarUrl(claim.identity.avatarUrl, imageProxySecret, claim.identity.subject), + url: getSafeHttpUrl(claim.identity.profileUrl) || undefined, + status: mapKeytraceVerificationStatus(claim), + proofMethod: mapProofMethod(claim.type), + addedAt: claim.claim.createdAt, + lastCheckedAt: + getOptionalClaimField(claim, 'lastVerifiedAt') || + claim.claim.retractedAt || + claim.claim.createdAt, + failureReason: claim.error || undefined, + })) +} + +type KeytraceFetchResult = + | { kind: 'success'; data: KeytraceResponse } + | { kind: 'no-claims' } + | { kind: 'error'; error: unknown } + +// Fetch real Keytrace profile data +async function fetchKeytraceProfile( + domain: string, + imageProxySecret: string, +): Promise { + try { + const result = await getClaimsForHandle(domain) + + if (!result.claims || result.claims.length === 0) { + return { kind: 'no-claims' } + } + + // Build profile from first claim's identity (they all belong to the same DID) + const firstClaim = result.claims[0] + if (!firstClaim) { + return { kind: 'no-claims' } + } + + return { + kind: 'success', + data: { + profile: { + name: firstClaim.identity.displayName || domainToDisplayName(domain), + avatar: toProfileAvatarUrl(firstClaim.identity.avatarUrl, domain, imageProxySecret), + description: `Identity profile for ${domain}`, + }, + accounts: mapClaimsToAccounts(result.claims, imageProxySecret), + }, + } + } catch (error) { + console.error('Failed to fetch Keytrace profile:', error) + return { kind: 'error', error } + } +} + +export default defineEventHandler(async event => { + const { imageProxySecret } = useRuntimeConfig(event) + const domain = getRouterParam(event, 'domain')?.trim().toLowerCase() + if (!domain) { + throw createError({ + statusCode: 400, + message: 'Domain is required', + }) + } + + // Try to fetch real Keytrace data + const keytraceData = await fetchKeytraceProfile(domain, imageProxySecret) + + if (keytraceData.kind === 'success') { + return keytraceData.data + } + + if (keytraceData.kind === 'no-claims') { + return buildFallbackProfile(domain, imageProxySecret) + } + + // If Keytrace is unavailable and mock mode isn't allowed, return a neutral profile. + return buildServiceUnavailableProfile(domain, imageProxySecret) +}) diff --git a/server/api/keytrace/reverify.post.ts b/server/api/keytrace/reverify.post.ts new file mode 100644 index 0000000000..b46a4fca08 --- /dev/null +++ b/server/api/keytrace/reverify.post.ts @@ -0,0 +1,93 @@ +import { getClaimsForHandle, type ClaimVerificationResult } from '@keytrace/claims' +import type { KeytraceReverifyRequest, KeytraceReverifyResponse } from '#shared/types/keytrace' +import { + getOptionalClaimField, + mapPlatformToClaimType, + mapKeytraceVerificationStatus, +} from '#server/utils/keytrace' + +function mapReverifyStatus(claim: ClaimVerificationResult): KeytraceReverifyResponse['status'] { + return mapKeytraceVerificationStatus(claim) +} + +function getReverifyLastCheckedAt(claim: ClaimVerificationResult): string { + return getOptionalClaimField(claim, 'lastVerifiedAt') || claim.claim.createdAt +} + +function matchesAccount( + claim: ClaimVerificationResult, + claimType: string, + username: string, + url?: string, +): boolean { + if (claim.type !== claimType) { + return false + } + + const normalizedSubject = claim.identity.subject.toLowerCase() + const normalizedUsername = username.toLowerCase() + if (normalizedSubject === normalizedUsername) { + return true + } + + const profileUrl = (claim.identity.profileUrl || '').toLowerCase() + const normalizedUrl = (url || '').toLowerCase() + if (normalizedUrl && profileUrl && profileUrl === normalizedUrl) { + return true + } + + return false +} + +export default defineEventHandler(async event => { + const body = await readBody(event) + + const identity = body?.identity?.trim().toLowerCase() + const platform = body?.platform?.trim().toLowerCase() + const username = body?.username?.trim() + + if (!identity || !platform || !username) { + throw createError({ + statusCode: 400, + message: 'identity, platform and username are required', + }) + } + + try { + const result = await getClaimsForHandle(identity) + const claimType = mapPlatformToClaimType(platform) + const matchedClaim = result.claims.find(claim => + matchesAccount(claim, claimType, username, body?.url), + ) + + if (!matchedClaim) { + return { + status: 'unverified', + lastCheckedAt: new Date().toISOString(), + failureReason: 'No matching Keytrace claim found for this account.', + } + } + + const response: KeytraceReverifyResponse = { + status: mapReverifyStatus(matchedClaim), + lastCheckedAt: getReverifyLastCheckedAt(matchedClaim), + failureReason: matchedClaim.error || undefined, + } + + if (matchedClaim.claim.retractedAt) { + response.status = 'unverified' + response.retractedAt = matchedClaim.claim.retractedAt + response.failureReason = response.failureReason || 'Keytrace claim was retracted.' + } + + return response + } catch (error) { + console.error('[keytrace] reverify failed', error) + + return { + status: 'unverified', + lastCheckedAt: new Date().toISOString(), + failureReason: 'Keytrace is temporarily unavailable. Please try again shortly.', + } + } +}) diff --git a/server/utils/keytrace.ts b/server/utils/keytrace.ts new file mode 100644 index 0000000000..4448994538 --- /dev/null +++ b/server/utils/keytrace.ts @@ -0,0 +1,93 @@ +import type { ClaimVerificationResult } from '@keytrace/claims' +import type { KeytraceVerificationStatus } from '#shared/types/keytrace' + +const STALE_THRESHOLD_DAYS = 30 + +const platformToClaimTypeMap: Record = { + github: 'github', + dns: 'dns', + mastodon: 'activitypub', + bluesky: 'bsky', + npm: 'npm', + tangled: 'tangled', + pgp: 'pgp', + twitter: 'twitter', + linkedin: 'linkedin', + instagram: 'instagram', + reddit: 'reddit', + hackernews: 'hackernews', + orcid: 'orcid', + itchio: 'itchio', + discord: 'discord', + steam: 'steam', +} + +const claimTypeToPlatformMap: Record = { + github: 'github', + dns: 'dns', + activitypub: 'mastodon', + bsky: 'bluesky', + npm: 'npm', + tangled: 'tangled', + pgp: 'pgp', + twitter: 'twitter', + linkedin: 'linkedin', + instagram: 'instagram', + reddit: 'reddit', + hackernews: 'hackernews', + orcid: 'orcid', + itchio: 'itchio', + discord: 'discord', + steam: 'steam', +} + +export function mapPlatformToClaimType(platform: string): string { + return platformToClaimTypeMap[platform] || platform +} + +export function mapClaimTypeToPlatform(type: string): string { + return claimTypeToPlatformMap[type] || type +} + +export function getOptionalClaimField( + claim: ClaimVerificationResult, + key: string, +): string | undefined { + const rawClaim = claim.claim as unknown as Record + const value = rawClaim[key] + return typeof value === 'string' ? value : undefined +} + +export function isStaleIsoDate(isoDate: string | undefined): boolean { + if (!isoDate) { + return false + } + + const timestamp = Date.parse(isoDate) + if (Number.isNaN(timestamp)) { + return false + } + + return Date.now() - timestamp > STALE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 +} + +export function mapKeytraceVerificationStatus( + claim: ClaimVerificationResult, +): KeytraceVerificationStatus { + const rawStatus = getOptionalClaimField(claim, 'status') + const lastVerifiedAt = getOptionalClaimField(claim, 'lastVerifiedAt') + + if (rawStatus === 'failed' || rawStatus === 'retracted') { + return 'failed' + } + + if (rawStatus === 'verified' || claim.verified) { + return isStaleIsoDate(lastVerifiedAt) ? 'stale' : 'verified' + } + + if (claim.error) { + return 'failed' + } + + return 'unverified' +} diff --git a/shared/types/keytrace.ts b/shared/types/keytrace.ts new file mode 100644 index 0000000000..dbc04d2fdf --- /dev/null +++ b/shared/types/keytrace.ts @@ -0,0 +1,42 @@ +export type KeytraceProfile = { + name: string + avatar: string + banner?: string + description: string +} + +export type KeytraceVerificationStatus = 'verified' | 'unverified' | 'stale' | 'failed' + +export type KeytraceProofMethod = 'dns' | 'github' | 'npm' | 'mastodon' | 'pgp' | 'other' + +export type KeytraceAccount = { + platform: string + username: string + displayName?: string + avatar?: string + url?: string + status: KeytraceVerificationStatus + proofMethod: KeytraceProofMethod + addedAt: string + lastCheckedAt: string + failureReason?: string +} + +export type KeytraceResponse = { + profile: KeytraceProfile + accounts: KeytraceAccount[] +} + +export type KeytraceReverifyRequest = { + identity: string + platform: string + username: string + url?: string +} + +export type KeytraceReverifyResponse = { + status: KeytraceVerificationStatus + lastCheckedAt: string + failureReason?: string + retractedAt?: string +} diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index aa16620760..d483dc8996 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -271,6 +271,8 @@ import FacetScatterChart from '~/components/Compare/FacetScatterChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import Likes from '~/components/Package/Likes.vue' +import AccountItem from '~/components/AccountItem.vue' +import LinkedAccounts from '~/components/LinkedAccounts.vue' import type { VueUiXyDatasetItem } from 'vue-data-ui' describe('component accessibility audits', () => { @@ -643,7 +645,11 @@ describe('component accessibility audits', () => { it('should have no accessibility violations as secondary button', async () => { const component = await mountSuspended(LinkBase, { - props: { to: 'http://example.com', disabled: true, variant: 'button-secondary' }, + props: { + to: 'http://example.com', + disabled: true, + variant: 'button-secondary', + }, slots: { default: 'Button link content' }, }) const results = await runAxe(component) @@ -652,7 +658,11 @@ describe('component accessibility audits', () => { it('should have no accessibility violations as primary button', async () => { const component = await mountSuspended(LinkBase, { - props: { to: 'http://example.com', disabled: true, variant: 'button-primary' }, + props: { + to: 'http://example.com', + disabled: true, + variant: 'button-primary', + }, slots: { default: 'Button link content' }, }) const results = await runAxe(component) @@ -904,6 +914,52 @@ describe('component accessibility audits', () => { }) }) + describe('LinkedAccounts', () => { + it('should have no accessibility violations with account list', async () => { + const component = await mountSuspended(LinkedAccounts, { + props: { + identity: 'npmx.dev', + accounts: [ + { + platform: 'github', + username: 'npmx-dev', + displayName: 'npmx-dev', + status: 'verified', + proofMethod: 'github', + addedAt: '2026-04-01T10:00:00.000Z', + lastCheckedAt: '2026-04-21T10:00:00.000Z', + url: 'https://github.com/npmx-dev', + }, + ], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('AccountItem', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(AccountItem, { + props: { + identity: 'npmx.dev', + account: { + platform: 'github', + username: 'npmx-dev', + displayName: 'npmx-dev', + status: 'verified', + proofMethod: 'github', + addedAt: '2026-04-01T10:00:00.000Z', + lastCheckedAt: '2026-04-21T10:00:00.000Z', + url: 'https://github.com/npmx-dev', + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('PackageHeader', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(PackageHeader, { @@ -3725,7 +3781,14 @@ describe('component accessibility audits', () => { files: { added: [{ path: 'new.ts', type: 'added' as const, newSize: 100 }], removed: [{ path: 'old.ts', type: 'removed' as const, oldSize: 50 }], - modified: [{ path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }], + modified: [ + { + path: 'changed.ts', + type: 'modified' as const, + oldSize: 200, + newSize: 250, + }, + ], }, dependencyChanges: [ { @@ -3750,7 +3813,12 @@ describe('component accessibility audits', () => { const mockAllChanges = [ { path: 'new.ts', type: 'added' as const, newSize: 100 }, { path: 'old.ts', type: 'removed' as const, oldSize: 50 }, - { path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }, + { + path: 'changed.ts', + type: 'modified' as const, + oldSize: 200, + newSize: 250, + }, ] const mockGroupedDeps = new Map([ @@ -3849,7 +3917,14 @@ describe('component accessibility audits', () => { files: { added: [{ path: 'new.ts', type: 'added' as const, newSize: 100 }], removed: [], - modified: [{ path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }], + modified: [ + { + path: 'changed.ts', + type: 'modified' as const, + oldSize: 200, + newSize: 250, + }, + ], }, dependencyChanges: [], stats: { @@ -3864,7 +3939,12 @@ describe('component accessibility audits', () => { const mockAllChanges = [ { path: 'new.ts', type: 'added' as const, newSize: 100 }, - { path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }, + { + path: 'changed.ts', + type: 'modified' as const, + oldSize: 200, + newSize: 250, + }, ] it('should have no accessibility violations when closed', async () => {