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
6 changes: 6 additions & 0 deletions .server-changes/pat-token-reveal-last-chars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

When you create a Personal Access Token, the generated token now shows its first and last few characters instead of being fully hidden, so you can confirm you copied the right value.
40 changes: 39 additions & 1 deletion apps/webapp/app/components/primitives/ClipboardField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,43 @@ const variants = {
},
};

const SECURE_MASK = "••••••••••••••••";

/**
* Builds the masked display string, optionally revealing the first/last few
* characters in cleartext so users can confirm a copied value. A custom mask
* string (when `secure` is a string) is always shown as-is.
*/
function maskValue(
value: string,
secure: boolean | string,
revealStart: number,
revealEnd: number
) {
if (typeof secure === "string") {
return secure;
}

const start = Math.max(0, revealStart);
const end = Math.max(0, revealEnd);

// Nothing to reveal, or revealing would leak the whole value: fully mask.
if ((start === 0 && end === 0) || start + end >= value.length) {
return SECURE_MASK;
}

const revealedStart = start > 0 ? value.slice(0, start) : "";
const revealedEnd = end > 0 ? value.slice(-end) : "";
return `${revealedStart}${SECURE_MASK}${revealedEnd}`;
}

type ClipboardFieldProps = {
value: string;
secure?: boolean | string;
/** When masked, reveal this many of the first characters in cleartext. */
secureRevealStart?: number;
/** When masked, reveal this many of the last characters in cleartext. */
secureRevealEnd?: number;
variant: keyof typeof variants;
className?: string;
icon?: React.ReactNode;
Expand All @@ -73,6 +107,8 @@ type ClipboardFieldProps = {
export function ClipboardField({
value,
secure = false,
secureRevealStart = 0,
secureRevealEnd = 0,
variant,
className,
icon,
Expand All @@ -87,6 +123,8 @@ export function ClipboardField({
setIsSecure(secure !== undefined && secure);
}, [secure]);

const maskedValue = maskValue(value, secure, secureRevealStart, secureRevealEnd);

return (
<span className={cn(container, fullWidth ? "w-full" : "max-w-fit", className)}>
{icon && (
Expand All @@ -100,7 +138,7 @@ export function ClipboardField({
<input
type="text"
ref={inputIcon}
value={isSecure ? (typeof secure === "string" ? secure : "••••••••••••••••") : value}
value={isSecure ? maskedValue : value}
readOnly={true}
className={cn(
"shrink grow select-all overflow-x-auto",
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/routes/account.tokens/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,9 @@ function CreatePersonalAccessToken({
</Callout>
<ClipboardField
secure
// 7-char "tr_pat_" prefix + 4 token chars, matching the tokens list display
secureRevealStart={11}
secureRevealEnd={4}
value={token.token}
variant={"secondary/medium"}
icon={<ShieldExclamationIcon className="size-5 text-success" />}
Expand Down