Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "{handle} entfernen",
"discordConnectHint": "Verbinde dich mit deinem Lizenzschlüssel, um die Pro-Rolle in unserem Community-Discord zu erhalten.",
"discordNoMembers": "Noch keine verbundenen Mitglieder.",
"discordRemoveError": "Discord-Mitglied konnte nicht entfernt werden. Bitte versuche es erneut."
"discordRemoveError": "Discord-Mitglied konnte nicht entfernt werden. Bitte versuche es erneut.",
"showKeyAria": "Lizenzschlüssel mit Endung {suffix} anzeigen",
"hideKeyAria": "Lizenzschlüssel mit Endung {suffix} ausblenden",
"copyKeyAria": "Lizenzschlüssel mit Endung {suffix} kopieren",
"copiedKey": "Kopiert",
"copyError": "Lizenzschlüssel konnte nicht kopiert werden. Bitte versuche es erneut."
},
"downloads": {
"heading": "Downloads",
Expand Down
7 changes: 6 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "Remove {handle}",
"discordConnectHint": "Connect with your license key to get the Pro role in our community Discord.",
"discordNoMembers": "No connected members yet.",
"discordRemoveError": "Failed to remove Discord member. Please try again."
"discordRemoveError": "Failed to remove Discord member. Please try again.",
"showKeyAria": "Show license key ending in {suffix}",
"hideKeyAria": "Hide license key ending in {suffix}",
"copyKeyAria": "Copy license key ending in {suffix}",
"copiedKey": "Copied",
"copyError": "Failed to copy license key. Please try again."
},
"downloads": {
"heading": "Downloads",
Expand Down
7 changes: 6 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "Eliminar {handle}",
"discordConnectHint": "Conecta con tu clave de licencia para obtener el rol Pro en nuestro Discord de la comunidad.",
"discordNoMembers": "Aún no hay miembros conectados.",
"discordRemoveError": "No se pudo eliminar el miembro de Discord. Inténtalo de nuevo."
"discordRemoveError": "No se pudo eliminar el miembro de Discord. Inténtalo de nuevo.",
"showKeyAria": "Mostrar clave de licencia que termina en {suffix}",
"hideKeyAria": "Ocultar clave de licencia que termina en {suffix}",
"copyKeyAria": "Copiar clave de licencia que termina en {suffix}",
"copiedKey": "Copiada",
"copyError": "No se pudo copiar la clave de licencia. Inténtalo de nuevo."
},
"downloads": {
"heading": "Descargas",
Expand Down
7 changes: 6 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "Retirer {handle}",
"discordConnectHint": "Connectez-vous avec votre clé de licence pour obtenir le rôle Pro sur notre Discord communautaire.",
"discordNoMembers": "Aucun membre connecté pour le moment.",
"discordRemoveError": "Échec du retrait du membre Discord. Veuillez réessayer."
"discordRemoveError": "Échec du retrait du membre Discord. Veuillez réessayer.",
"showKeyAria": "Afficher la clé de licence se terminant par {suffix}",
"hideKeyAria": "Masquer la clé de licence se terminant par {suffix}",
"copyKeyAria": "Copier la clé de licence se terminant par {suffix}",
"copiedKey": "Copiée",
"copyError": "Échec de la copie de la clé de licence. Veuillez réessayer."
},
"downloads": {
"heading": "Téléchargements",
Expand Down
7 changes: 6 additions & 1 deletion messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "Rimuovi {handle}",
"discordConnectHint": "Collegati con la tua chiave di licenza per ottenere il ruolo Pro nel nostro Discord della community.",
"discordNoMembers": "Ancora nessun membro collegato.",
"discordRemoveError": "Impossibile rimuovere il membro Discord. Riprova."
"discordRemoveError": "Impossibile rimuovere il membro Discord. Riprova.",
"showKeyAria": "Mostra la chiave di licenza che termina con {suffix}",
"hideKeyAria": "Nascondi la chiave di licenza che termina con {suffix}",
"copyKeyAria": "Copia la chiave di licenza che termina con {suffix}",
"copiedKey": "Copiata",
"copyError": "Impossibile copiare la chiave di licenza. Riprova."
},
"downloads": {
"heading": "Download",
Expand Down
7 changes: 6 additions & 1 deletion messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "{handle} を削除",
"discordConnectHint": "ライセンスキーで連携すると、コミュニティDiscordでProロールを取得できます。",
"discordNoMembers": "まだ連携済みのメンバーはいません。",
"discordRemoveError": "Discordメンバーの削除に失敗しました。もう一度お試しください。"
"discordRemoveError": "Discordメンバーの削除に失敗しました。もう一度お試しください。",
"showKeyAria": "末尾が {suffix} のライセンスキーを表示",
"hideKeyAria": "末尾が {suffix} のライセンスキーを非表示",
"copyKeyAria": "末尾が {suffix} のライセンスキーをコピー",
"copiedKey": "コピーしました",
"copyError": "ライセンスキーをコピーできませんでした。もう一度お試しください。"
},
"downloads": {
"heading": "ダウンロード",
Expand Down
7 changes: 6 additions & 1 deletion messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "{handle} 제거",
"discordConnectHint": "라이선스 키로 연결하면 커뮤니티 Discord에서 Pro 역할을 받을 수 있습니다.",
"discordNoMembers": "아직 연결된 멤버가 없습니다.",
"discordRemoveError": "Discord 멤버를 제거하지 못했습니다. 다시 시도해 주세요."
"discordRemoveError": "Discord 멤버를 제거하지 못했습니다. 다시 시도해 주세요.",
"showKeyAria": "{suffix}(으)로 끝나는 라이선스 키 표시",
"hideKeyAria": "{suffix}(으)로 끝나는 라이선스 키 숨기기",
"copyKeyAria": "{suffix}(으)로 끝나는 라이선스 키 복사",
"copiedKey": "복사됨",
"copyError": "라이선스 키를 복사하지 못했습니다. 다시 시도해 주세요."
},
"downloads": {
"heading": "다운로드",
Expand Down
7 changes: 6 additions & 1 deletion messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "{handle} verwijderen",
"discordConnectHint": "Verbind met je licentiesleutel om de Pro-rol in onze community-Discord te krijgen.",
"discordNoMembers": "Nog geen verbonden leden.",
"discordRemoveError": "Kan Discord-lid niet verwijderen. Probeer het opnieuw."
"discordRemoveError": "Kan Discord-lid niet verwijderen. Probeer het opnieuw.",
"showKeyAria": "Licentiesleutel die eindigt op {suffix} tonen",
"hideKeyAria": "Licentiesleutel die eindigt op {suffix} verbergen",
"copyKeyAria": "Licentiesleutel die eindigt op {suffix} kopiëren",
"copiedKey": "Gekopieerd",
"copyError": "Kopiëren van licentiesleutel mislukt. Probeer het opnieuw."
},
"downloads": {
"heading": "Downloads",
Expand Down
7 changes: 6 additions & 1 deletion messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "Remover {handle}",
"discordConnectHint": "Ligue com a sua chave de licença para obter o papel Pro no nosso Discord da comunidade.",
"discordNoMembers": "Ainda não há membros ligados.",
"discordRemoveError": "Falha ao remover o membro do Discord. Tente novamente."
"discordRemoveError": "Falha ao remover o membro do Discord. Tente novamente.",
"showKeyAria": "Mostrar chave de licença terminada em {suffix}",
"hideKeyAria": "Ocultar chave de licença terminada em {suffix}",
"copyKeyAria": "Copiar chave de licença terminada em {suffix}",
"copiedKey": "Copiada",
"copyError": "Falha ao copiar a chave de licença. Tente novamente."
},
"downloads": {
"heading": "Transferências",
Expand Down
7 changes: 6 additions & 1 deletion messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,12 @@
"discordRemoveAria": "移除 {handle}",
"discordConnectHint": "使用你的许可证密钥连接,即可在我们的社区 Discord 中获得 Pro 身份组。",
"discordNoMembers": "暂无已连接的成员。",
"discordRemoveError": "移除 Discord 成员失败。请重试。"
"discordRemoveError": "移除 Discord 成员失败。请重试。",
"showKeyAria": "显示以 {suffix} 结尾的许可证密钥",
"hideKeyAria": "隐藏以 {suffix} 结尾的许可证密钥",
"copyKeyAria": "复制以 {suffix} 结尾的许可证密钥",
"copiedKey": "已复制",
"copyError": "复制许可证密钥失败。请重试。"
},
"downloads": {
"heading": "下载",
Expand Down
71 changes: 71 additions & 0 deletions src/components/account/licenses-client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,77 @@ describe('LicensesClient', () => {
expect(screen.getByText('****-****-MNOP')).toBeInTheDocument()
})

it('reveals the full license key when the key is clicked, then re-masks', () => {
render(<LicensesClient initialLicenses={[makeLicense()]} />)

const toggle = screen.getByRole('button', {
name: 'Show license key ending in MNOP',
})
expect(screen.getByText('****-****-MNOP')).toBeInTheDocument()
expect(screen.queryByText('ABCD-EFGH-IJKL-MNOP')).not.toBeInTheDocument()

fireEvent.click(toggle)
expect(screen.getByText('ABCD-EFGH-IJKL-MNOP')).toBeInTheDocument()
expect(screen.queryByText('****-****-MNOP')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Hide license key ending in MNOP' })
).toHaveAttribute('aria-pressed', 'true')

fireEvent.click(
screen.getByRole('button', { name: 'Hide license key ending in MNOP' })
)
expect(screen.getByText('****-****-MNOP')).toBeInTheDocument()
expect(screen.queryByText('ABCD-EFGH-IJKL-MNOP')).not.toBeInTheDocument()
})

it('copies the full license key to the clipboard when the copy button is clicked', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})

render(<LicensesClient initialLicenses={[makeLicense()]} />)

const copyButton = screen.getByRole('button', {
name: 'Copy license key ending in MNOP',
})
fireEvent.click(copyButton)

expect(writeText).toHaveBeenCalledWith('ABCD-EFGH-IJKL-MNOP')
// Confirmation state flips the button's accessible name to "Copied".
expect(
await screen.findByRole('button', { name: 'Copied' })
).toBeInTheDocument()
})

it('gives each card a distinct accessible name and reveals keys independently', () => {
render(
<LicensesClient
initialLicenses={[
makeLicense({ id: 'lic-1', key: 'ABCD-EFGH-IJKL-MNOP' }),
makeLicense({ id: 'lic-2', key: 'WXYZ-WXYZ-WXYZ-0000' }),
]}
/>
)

// Each card's toggle has a suffix-disambiguated accessible name, so
// screen readers (and this test) can target one card without getAllByRole.
const firstToggle = screen.getByRole('button', {
name: 'Show license key ending in MNOP',
})
expect(
screen.getByRole('button', { name: 'Show license key ending in 0000' })
).toBeInTheDocument()

fireEvent.click(firstToggle)

// Only the clicked card reveals; the other stays masked.
expect(screen.getByText('ABCD-EFGH-IJKL-MNOP')).toBeInTheDocument()
expect(screen.getByText('****-****-0000')).toBeInTheDocument()
expect(screen.queryByText('WXYZ-WXYZ-WXYZ-0000')).not.toBeInTheDocument()
})

it('shows download button for active licenses', () => {
render(
<LicensesClient initialLicenses={[makeLicense({ status: 'active' })]} />
Expand Down
93 changes: 83 additions & 10 deletions src/components/account/licenses-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { DividedList, Row } from '@/components/ui/row'
import { Alert } from '@/components/ui/alert'
import { EmptyState } from '@/components/ui/empty-state'
import { AccountNotice } from '@/components/account/account-notice'
import { Key, Monitor, Trash2, Download } from 'lucide-react'
import { Key, Monitor, Trash2, Download, Copy, Check } from 'lucide-react'
import { Link } from '@/i18n/navigation'
import { formatDateForLocale } from '@/lib/date-format'
import type { CanonicalLicenseStatus } from '@/lib/license-status'
Expand Down Expand Up @@ -88,6 +88,13 @@ export function LicensesClient({
const t = useTranslations('account.licenses')
const tStatus = useTranslations('account.licenseStatus')
const [licenses, setLicenses] = useState<License[]>(initialLicenses)
// License keys render masked; clicking a key reveals its full value for
// that card only. Tracked per licence id so revealing one leaves the rest
// masked.
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(() => new Set())
// Licence id whose key was just copied, so its button can flash a
// "Copied" confirmation. Cleared after a short delay.
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [deactivating, setDeactivating] = useState<string | null>(null)
const [removingDiscordMember, setRemovingDiscordMember] = useState<string | null>(null)
Expand Down Expand Up @@ -185,6 +192,32 @@ export function LicensesClient({
return '****-****-' + key.slice(-4)
}

const toggleRevealKey = (licenseId: string) => {
setRevealedKeys((prev) => {
const next = new Set(prev)
if (next.has(licenseId)) {
next.delete(licenseId)
} else {
next.add(licenseId)
}
return next
})
}

const handleCopyKey = async (licenseId: string, key: string) => {
try {
await navigator.clipboard.writeText(key)
setCopiedKey(licenseId)
// Reset the confirmation, but only if this card is still the copied
// one (guards against clobbering a later copy of a different card).
setTimeout(() => {
setCopiedKey((current) => (current === licenseId ? null : current))
}, 2000)
} catch {
setError(t('copyError'))
}
}

// Keygen can report status "active" after the expiry date has passed;
// present those licenses as expired so the UI matches download entitlement.
// (Shared rule: unparseable expiry fails closed, matching the server.)
Expand Down Expand Up @@ -233,22 +266,62 @@ export function LicensesClient({
const discordAccess =
discordAccessByLicenseState[license.id] ?? emptyDiscordAccess(license.id)
const discordMembers = discordAccess.members
const keyRevealed = revealedKeys.has(license.id)
const keyCopied = copiedKey === license.id
// Last-4 of the key distinguishes each card's controls in the
// accessible name — with multiple licences the buttons would
// otherwise all announce the same label to screen readers.
const keySuffix = license.key.slice(-4)
return (
<Card key={license.id}>
<CardHeader>
{/* flex-wrap so the badge/plan cluster drops below the key on
narrow phones instead of squeezing it. */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<CardTitle className="flex items-center gap-2.5 text-lg">
<span
aria-hidden="true"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-wcpos-red/10 text-wcpos-red-accent"
<CardTitle className="flex min-w-0 items-center gap-1.5 text-lg">
<button
type="button"
onClick={() => toggleRevealKey(license.id)}
aria-pressed={keyRevealed}
title={t(keyRevealed ? 'hideKeyAria' : 'showKeyAria', {
suffix: keySuffix,
})}
aria-label={t(keyRevealed ? 'hideKeyAria' : 'showKeyAria', {
suffix: keySuffix,
})}
className="group flex min-w-0 items-center gap-2.5 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wcpos-red/40"
>
<Key className="h-4 w-4" />
</span>
<code className="font-mono text-sm tracking-wider">
{maskKey(license.key)}
</code>
<span
aria-hidden="true"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-wcpos-red/10 text-wcpos-red-accent transition-colors group-hover:bg-wcpos-red/20"
>
<Key className="h-4 w-4" />
</span>
<code className="break-all font-mono text-sm tracking-wider">
{keyRevealed ? license.key : maskKey(license.key)}
</code>
</button>
<button
type="button"
onClick={() => handleCopyKey(license.id, license.key)}
title={
keyCopied
? t('copiedKey')
: t('copyKeyAria', { suffix: keySuffix })
}
aria-label={
keyCopied
? t('copiedKey')
: t('copyKeyAria', { suffix: keySuffix })
}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wcpos-red/40"
>
{keyCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</CardTitle>
<div className="flex items-center gap-2">
<StatusBadge
Expand Down
Loading