DRY pass #3: one one-time secret reveal (#5373)

AccountView's device-token reveal was OneTimeLink's markup and copy handler
again. OneTimeLink becomes OneTimeSecret (value, note, noun = link | token),
and the account page shows a new token through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:13:18 -04:00
co-authored by Claude Opus 5.5
parent a0249564e2
commit 498b23789c
4 changed files with 36 additions and 56 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ import { onMounted, ref } from "vue";
import { api } from "../api/client"; import { api } from "../api/client";
import { useSessionStore, type User } from "../stores/session"; import { useSessionStore, type User } from "../stores/session";
import { appLink } from "../router/links"; import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue"; import OneTimeSecret from "./OneTimeSecret.vue";
import { useRowAction, useLoad } from "../composables/useAction"; import { useRowAction, useLoad } from "../composables/useAction";
// Admin: the accounts on this instance, and a password reset link for any of them // Admin: the accounts on this instance, and a password reset link for any of them
@@ -45,9 +45,9 @@ onMounted(() => {
<section class="flex flex-col gap-5"> <section class="flex flex-col gap-5">
<h2 class="section-label">People</h2> <h2 class="section-label">People</h2>
<OneTimeLink <OneTimeSecret
v-if="fresh" v-if="fresh"
:link="fresh.link" :value="fresh.link"
:note="`Password reset link for ${fresh.email}. It works once, within an hour.`" :note="`Password reset link for ${fresh.email}. It works once, within an hour.`"
@done="fresh = null" @done="fresh = null"
/> />
+3 -3
View File
@@ -5,7 +5,7 @@ import { errorMessage } from "../api/errors";
import { appLink } from "../router/links"; import { appLink } from "../router/links";
import BaseButton from "./BaseButton.vue"; import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue"; import BaseInput from "./BaseInput.vue";
import OneTimeLink from "./OneTimeLink.vue"; import OneTimeSecret from "./OneTimeSecret.vue";
import { toastOnFailure, useLoad } from "../composables/useAction"; import { toastOnFailure, useLoad } from "../composables/useAction";
// Admin: invite one person to register while registration stays closed (#5172). // Admin: invite one person to register while registration stays closed (#5172).
@@ -97,9 +97,9 @@ onMounted(() => {
<section class="flex flex-col gap-5"> <section class="flex flex-col gap-5">
<h2 class="section-label">Invites</h2> <h2 class="section-label">Invites</h2>
<OneTimeLink <OneTimeSecret
v-if="freshLink" v-if="freshLink"
:link="freshLink" :value="freshLink"
note="Send this to the person you're inviting." note="Send this to the person you're inviting."
@done="freshLink = ''" @done="freshLink = ''"
/> />
@@ -2,22 +2,27 @@
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
// A link carrying a token the server keeps only a hash of, so this is the one time // A secret the server keeps only a hash of, so this is the one time it can be shown:
// it can be shown: invites (#5172) and password reset links (#5173). // invite links (#5172), password reset links (#5173) and device tokens.
const props = defineProps<{ const props = withDefaults(
link: string; defineProps<{
/** What the link is, shown above it. */ value: string;
note: string; /** What it is, shown above it. */
}>(); note: string;
/** What to call it on the copy button and in the toast. */
noun?: "link" | "token";
}>(),
{ noun: "link" },
);
const emit = defineEmits<{ done: [] }>(); const emit = defineEmits<{ done: [] }>();
const ui = useUiStore(); const ui = useUiStore();
async function copy() { async function copy() {
try { try {
await navigator.clipboard.writeText(props.link); await navigator.clipboard.writeText(props.value);
ui.showToast("Link copied."); ui.showToast(props.noun === "token" ? "Token copied." : "Link copied.");
} catch { } catch {
ui.showToast("Couldn't copy — select and copy it manually."); ui.showToast("Couldn't copy — select and copy it manually.");
} }
@@ -31,9 +36,15 @@ async function copy() {
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">
<code <code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100" class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ link }}</code >{{ value }}</code
>
<button
type="button"
class="icon-btn shrink-0"
:title="`Copy ${noun}`"
:aria-label="`Copy ${noun}`"
@click="copy"
> >
<button type="button" class="icon-btn shrink-0" title="Copy link" aria-label="Copy link" @click="copy">
<Icon name="copy" /> <Icon name="copy" />
</button> </button>
</div> </div>
+8 -39
View File
@@ -8,6 +8,7 @@ import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue"; import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue"; import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue"; import Icon from "../components/Icon.vue";
import OneTimeSecret from "../components/OneTimeSecret.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime"; import { formatDateTime } from "../notes/datetime";
@@ -71,15 +72,6 @@ async function link() {
} }
} }
async function copyToken() {
try {
await navigator.clipboard.writeText(freshToken.value);
ui.showToast("Token copied to clipboard.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(id: string, name: string) { async function revoke(id: string, name: string) {
if (!window.confirm(`Revoke "${name}"? That device will need to link again to sync.`)) return; if (!window.confirm(`Revoke "${name}"? That device will need to link again to sync.`)) return;
try { try {
@@ -147,37 +139,14 @@ onMounted(() => {
Hides itself when the server holds none. --> Hides itself when the server holds none. -->
<ClientDownloads /> <ClientDownloads />
<!-- One-time token reveal --> <OneTimeSecret
<div
v-if="freshToken" v-if="freshToken"
class="mb-6 rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10" class="mb-6"
> :value="freshToken"
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100"> noun="token"
Copy this token now — it won't be shown again. note="This device's token."
</p> @done="freshToken = ''"
<div class="mt-2 flex items-center gap-2"> />
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshToken }}</code
>
<button
type="button"
class="icon-btn shrink-0"
title="Copy token"
aria-label="Copy token"
@click="copyToken"
>
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshToken = ''"
>
Done
</button>
</div>
<!-- Create a device token --> <!-- Create a device token -->
<form class="mb-8 flex items-end gap-3" @submit.prevent="link"> <form class="mb-8 flex items-end gap-3" @submit.prevent="link">