SlicerAccessCodeCard.tsx 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import { useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { Check, Copy, Eye, EyeOff, KeyRound, Loader2, RefreshCw } from 'lucide-react';
  5. import { multiVirtualPrinterApi } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { ConfirmModal } from './ConfirmModal';
  8. import { useToast } from '../contexts/ToastContext';
  9. import { useAuth } from '../contexts/AuthContext';
  10. import { copyTextToClipboard } from '../utils/clipboard';
  11. /**
  12. * The signed-in user's personal access code for virtual printers (#1430),
  13. * at the top of Settings > Virtual Printer.
  14. *
  15. * Entered in their slicer instead of a virtual printer's own access code, it
  16. * marks what they send as theirs. A virtual printer checks at every sign-in
  17. * whether they may send to it. Renders nothing while auth is off.
  18. */
  19. export function SlicerAccessCodeCard() {
  20. const { t } = useTranslation();
  21. const { showToast } = useToast();
  22. const queryClient = useQueryClient();
  23. const { user, authEnabled } = useAuth();
  24. const [revealed, setRevealed] = useState(false);
  25. const [copied, setCopied] = useState(false);
  26. const [confirmRenew, setConfirmRenew] = useState(false);
  27. // Per user: on a shared PC the next person to sign in must never see the
  28. // code cached for the one before.
  29. const queryKey = ['my-slicer-access-code', user?.id ?? null];
  30. const enabled = authEnabled && !!user;
  31. const { data, isLoading, isError } = useQuery({
  32. queryKey,
  33. queryFn: multiVirtualPrinterApi.getMyAccessCode,
  34. enabled,
  35. gcTime: 0,
  36. });
  37. const renew = useMutation({
  38. mutationFn: multiVirtualPrinterApi.renewMyAccessCode,
  39. onSuccess: (result) => {
  40. queryClient.setQueryData(queryKey, result);
  41. setRevealed(true);
  42. showToast(t('slicerAccessCode.renewed'));
  43. },
  44. onError: (error: Error) => showToast(error.message || t('slicerAccessCode.loadFailed'), 'error'),
  45. });
  46. if (!enabled) return null;
  47. const copy = async () => {
  48. if (!data) return;
  49. if (await copyTextToClipboard(data.code)) {
  50. setCopied(true);
  51. setTimeout(() => setCopied(false), 2000);
  52. } else {
  53. showToast(t('slicerAccessCode.copyFailed'), 'error');
  54. }
  55. };
  56. const iconButton =
  57. 'inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors';
  58. return (
  59. <Card>
  60. <CardContent className="py-3 px-4">
  61. <div className="flex items-start gap-3">
  62. <KeyRound className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
  63. <div className="flex-1 min-w-0">
  64. <div className="flex flex-wrap items-center justify-between gap-3">
  65. <p className="text-sm text-white font-medium">{t('slicerAccessCode.title')}</p>
  66. {isLoading ? (
  67. <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
  68. ) : isError || !data ? (
  69. <p className="text-xs text-red-600 dark:text-red-400">{t('slicerAccessCode.loadFailed')}</p>
  70. ) : (
  71. <div className="flex flex-wrap items-center gap-2">
  72. <code
  73. data-testid="slicer-access-code"
  74. className="font-mono text-sm tracking-[0.25em] text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-2 py-0.5"
  75. >
  76. {revealed ? data.code : '•'.repeat(data.code.length)}
  77. </code>
  78. <button
  79. onClick={() => setRevealed(!revealed)}
  80. aria-label={revealed ? t('slicerAccessCode.hide') : t('slicerAccessCode.show')}
  81. className={iconButton}
  82. >
  83. {revealed ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
  84. </button>
  85. <button onClick={copy} aria-label={t('slicerAccessCode.copy')} className={iconButton}>
  86. {copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
  87. </button>
  88. <button
  89. onClick={() => setConfirmRenew(true)}
  90. disabled={renew.isPending}
  91. className={iconButton}
  92. >
  93. {renew.isPending ? (
  94. <Loader2 className="w-3.5 h-3.5 animate-spin" />
  95. ) : (
  96. <RefreshCw className="w-3.5 h-3.5" />
  97. )}
  98. {t('slicerAccessCode.renew')}
  99. </button>
  100. </div>
  101. )}
  102. </div>
  103. <p className="text-xs text-bambu-gray mt-1">{t('slicerAccessCode.description')}</p>
  104. <ul className="text-xs text-bambu-gray list-disc pl-4 mt-1 space-y-0.5">
  105. <li>{t('slicerAccessCode.hintChecked')}</li>
  106. <li>{t('slicerAccessCode.hintCamera')}</li>
  107. <li>{t('slicerAccessCode.hintSecret')}</li>
  108. </ul>
  109. </div>
  110. </div>
  111. </CardContent>
  112. {confirmRenew && (
  113. <ConfirmModal
  114. title={t('slicerAccessCode.renewTitle')}
  115. message={t('slicerAccessCode.renewConfirm')}
  116. confirmText={t('slicerAccessCode.renew')}
  117. variant="warning"
  118. onConfirm={() => {
  119. setConfirmRenew(false);
  120. renew.mutate();
  121. }}
  122. onCancel={() => setConfirmRenew(false)}
  123. />
  124. )}
  125. </Card>
  126. );
  127. }