From c1001799b9dc15a54d5f9b4866019027d7ed8cf7 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 20:23:37 +0000 Subject: [PATCH 1/3] fix(portal): remove display-name editing, endpoint no longer exists The server deletes PUT /v1/auth/display-name. Drop the account page's Display name row and updateDisplayName helper rather than repoint it at handle-claim (out of scope). Email row already covers identity. --- portal/app/account/page.tsx | 64 +------------------------------------ portal/lib/api.ts | 8 ----- 2 files changed, 1 insertion(+), 71 deletions(-) diff --git a/portal/app/account/page.tsx b/portal/app/account/page.tsx index 9df989f..f44c600 100644 --- a/portal/app/account/page.tsx +++ b/portal/app/account/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; -import { getCheckoutUrl, getPortalUrl, updateSeats, refreshJwt, getSubscription, cancelSubscription, resumeSubscription, resendVerificationEmail, updateDisplayName } from "../../lib/api"; +import { getCheckoutUrl, getPortalUrl, updateSeats, refreshJwt, getSubscription, cancelSubscription, resumeSubscription, resendVerificationEmail } from "../../lib/api"; import EditEmailModal from "./EditEmailModal"; import ChangePasswordModal from "./ChangePasswordModal"; @@ -130,11 +130,6 @@ export default function AccountPage() { const [verificationResent, setVerificationResent] = useState(false); const [showEditEmail, setShowEditEmail] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false); - const [accountDisplayName, setAccountDisplayName] = useState(""); - const [editingDisplayName, setEditingDisplayName] = useState(false); - const [displayNameInput, setDisplayNameInput] = useState(""); - const [displayNameLoading, setDisplayNameLoading] = useState(false); - const [displayNameError, setDisplayNameError] = useState(""); useEffect(() => { // Ingest ?token= from desktop app handoff (Bug 7) @@ -194,7 +189,6 @@ export default function AccountPage() { const me = await getMe(activeToken); setAccountId(me.account_id); setAccountEmail(me.email); - setAccountDisplayName(me.display_name ?? ""); } catch { /* non-critical */ } // Trial expired modal @@ -417,62 +411,6 @@ export default function AccountPage() {

— account

- {/* Display name row */} -
-
-

Display name

- {editingDisplayName ? ( -
- { setDisplayNameInput(e.target.value); setDisplayNameError(""); }} - className="bg-[#0a0a0f] border border-[#1e1e2e] focus:border-zinc-600 rounded-lg px-3 py-1.5 text-sm text-white outline-none" - /> - - -
- ) : ( -

{accountDisplayName || "—"}

- )} - {displayNameError &&

{displayNameError}

} -
- {!editingDisplayName && ( - - )} -
{/* Email + password row */}
diff --git a/portal/lib/api.ts b/portal/lib/api.ts index 2fd5ade..cd6b2f4 100644 --- a/portal/lib/api.ts +++ b/portal/lib/api.ts @@ -90,14 +90,6 @@ export function getMe(token: string): Promise { return request("/v1/auth/me", {}, token); } -export function updateDisplayName(displayName: string, token: string): Promise { - return request( - "/v1/auth/display-name", - { method: "PUT", body: JSON.stringify({ display_name: displayName }) }, - token, - ); -} - export function updateEmail(newEmail: string, authKey: string, token: string): Promise { return request( "/v1/auth/email", From ac33adcc785b9205aaa8c0a6198a93cfe035a21b Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 21:19:39 +0000 Subject: [PATCH 2/3] feat(portal): add Handle row to account page Replaces the removed display-name editor's slot with a Handle row: viewable by everyone with a Copy button, editable via a Change handle modal for Pro and above. Free users see the value plus a note that they're still reachable. A lapsed-Pro user keeps their custom handle and the edit gate is only ever on current tier, never handle_is_custom. --- portal/app/account/ChangeHandleModal.tsx | 95 ++++++++++++++++++++++++ portal/app/account/page.tsx | 55 ++++++++++++++ portal/lib/api.ts | 10 +++ 3 files changed, 160 insertions(+) create mode 100644 portal/app/account/ChangeHandleModal.tsx diff --git a/portal/app/account/ChangeHandleModal.tsx b/portal/app/account/ChangeHandleModal.tsx new file mode 100644 index 0000000..f31b942 --- /dev/null +++ b/portal/app/account/ChangeHandleModal.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { getMe, updateHandle, ApiError } from "../../lib/api"; + +interface Props { + currentHandle: string; + token: string; + onClose: () => void; + onSuccess: (newHandle: string) => void; +} + +const HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/; + +function isValidHandleFormat(h: string): boolean { + return h.length >= 3 && h.length <= 30 && HANDLE_PATTERN.test(h); +} + +export default function ChangeHandleModal({ currentHandle, token, onClose, onSuccess }: Props) { + const [newHandle, setNewHandle] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + const [savedHandle, setSavedHandle] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmed = newHandle.trim().toLowerCase(); + if (!isValidHandleFormat(trimmed)) { + setError("3-30 characters: lowercase letters, digits, hyphens, or underscores. Cannot start or end with a separator."); + return; + } + + setLoading(true); + setError(""); + try { + await updateHandle(trimmed, token); + const me = await getMe(token); + setSavedHandle(me.handle); + setDone(true); + onSuccess(me.handle); + } catch (err) { + if (err instanceof ApiError && err.status === 402) { + setError("Custom handles require Pro or above."); + } else if (err instanceof ApiError && err.status === 409) { + setError("That handle is taken."); + } else if (err instanceof ApiError && err.status === 422) { + setError("That handle isn't valid."); + } else if (err instanceof ApiError && err.status === 429) { + setError("You can only rename your handle once every 30 days."); + } else { + setError(err instanceof Error ? err.message : "Failed to update handle."); + } + } finally { + setLoading(false); + } + } + + return ( +
+
e.stopPropagation()}> +

Change handle

+

Current: @{currentHandle}

+ + {done ? ( +
+

Handle updated to @{savedHandle}.

+ +
+ ) : ( +
+ setNewHandle(e.target.value)} + autoFocus + maxLength={30} + className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" + /> +

+ 3-30 characters: lowercase letters, digits, hyphens, or underscores. Cannot start or end with a separator. + You can rename again in 30 days. +

+ {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/portal/app/account/page.tsx b/portal/app/account/page.tsx index f44c600..9fb7b2d 100644 --- a/portal/app/account/page.tsx +++ b/portal/app/account/page.tsx @@ -6,6 +6,7 @@ import Image from "next/image"; import { getCheckoutUrl, getPortalUrl, updateSeats, refreshJwt, getSubscription, cancelSubscription, resumeSubscription, resendVerificationEmail } from "../../lib/api"; import EditEmailModal from "./EditEmailModal"; import ChangePasswordModal from "./ChangePasswordModal"; +import ChangeHandleModal from "./ChangeHandleModal"; const TRIAL_EXPIRED_MODAL_KEY = "voltius_trial_expired_shown"; const DOWNLOAD_URL = "https://voltius.app#download"; @@ -130,6 +131,9 @@ export default function AccountPage() { const [verificationResent, setVerificationResent] = useState(false); const [showEditEmail, setShowEditEmail] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false); + const [accountHandle, setAccountHandle] = useState(""); + const [showChangeHandle, setShowChangeHandle] = useState(false); + const [handleCopied, setHandleCopied] = useState(false); useEffect(() => { // Ingest ?token= from desktop app handoff (Bug 7) @@ -189,6 +193,7 @@ export default function AccountPage() { const me = await getMe(activeToken); setAccountId(me.account_id); setAccountEmail(me.email); + setAccountHandle(me.handle); } catch { /* non-critical */ } // Trial expired modal @@ -309,6 +314,15 @@ export default function AccountPage() { } } + async function handleCopyHandle() { + if (!accountHandle) return; + try { + await navigator.clipboard.writeText(`@${accountHandle}`); + setHandleCopied(true); + setTimeout(() => setHandleCopied(false), 1500); + } catch { /* clipboard unavailable */ } + } + function handleSignOut() { sessionStorage.clear(); router.replace("/signin"); @@ -374,6 +388,18 @@ export default function AccountPage() { /> )} + {showChangeHandle && token && ( + setShowChangeHandle(false)} + onSuccess={(newHandle) => { + setAccountHandle(newHandle); + setShowChangeHandle(false); + }} + /> + )} + {showChangePassword && token && accountId && (

— account

+ {/* Handle row */} +
+
+

Handle

+

{accountHandle ? `@${accountHandle}` : "—"}

+ {displayTier === "free" && ( +
+

Custom handles are a Pro feature.

+

Others can still reach you at this handle.

+
+ )} +
+
+ + {displayTier !== "free" && ( + + )} +
+
{/* Email + password row */}
diff --git a/portal/lib/api.ts b/portal/lib/api.ts index cd6b2f4..f525242 100644 --- a/portal/lib/api.ts +++ b/portal/lib/api.ts @@ -51,6 +51,8 @@ export interface MeResponse { trial_ends_at: number | null; email_verified: boolean; wrapped_user_secrets: string | null; + handle: string; + handle_is_custom: boolean; } export interface CheckoutResponse { @@ -98,6 +100,14 @@ export function updateEmail(newEmail: string, authKey: string, token: string): P ); } +export function updateHandle(handle: string, token: string): Promise { + return request( + "/v1/users/me/handle", + { method: "PUT", body: JSON.stringify({ handle }) }, + token, + ); +} + export function updatePassword( oldAuthKey: string, newAuthKey: string, From dac3aad773ba853fd4e281e2792fc806e492ea30 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 21:45:06 +0000 Subject: [PATCH 3/3] fix: correct handle rename failure UX and Pro-gate timing ChangeHandleModal: the post-rename getMe() refetch shared a try with updateHandle(), so a network blip on the refetch reported a successful rename as "Failed to update handle." Give it its own try/catch and fall back to the trimmed input on failure. Also strip a leading "@" before validating, since the server accepts it and the UI's own subtitle models the handle as "@name". page.tsx: guard the Pro-only "Change handle" button against the null tier during initial load, and hide Copy when there is no handle to copy. --- portal/app/account/ChangeHandleModal.tsx | 22 ++++++++++++++++------ portal/app/account/page.tsx | 16 +++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/portal/app/account/ChangeHandleModal.tsx b/portal/app/account/ChangeHandleModal.tsx index f31b942..d149426 100644 --- a/portal/app/account/ChangeHandleModal.tsx +++ b/portal/app/account/ChangeHandleModal.tsx @@ -25,7 +25,7 @@ export default function ChangeHandleModal({ currentHandle, token, onClose, onSuc async function handleSubmit(e: FormEvent) { e.preventDefault(); - const trimmed = newHandle.trim().toLowerCase(); + const trimmed = newHandle.trim().toLowerCase().replace(/^@+/, ""); if (!isValidHandleFormat(trimmed)) { setError("3-30 characters: lowercase letters, digits, hyphens, or underscores. Cannot start or end with a separator."); return; @@ -35,10 +35,6 @@ export default function ChangeHandleModal({ currentHandle, token, onClose, onSuc setError(""); try { await updateHandle(trimmed, token); - const me = await getMe(token); - setSavedHandle(me.handle); - setDone(true); - onSuccess(me.handle); } catch (err) { if (err instanceof ApiError && err.status === 402) { setError("Custom handles require Pro or above."); @@ -51,9 +47,23 @@ export default function ChangeHandleModal({ currentHandle, token, onClose, onSuc } else { setError(err instanceof Error ? err.message : "Failed to update handle."); } - } finally { setLoading(false); + return; + } + + // The rename already committed above. This refetch is best-effort display + // polish — its failure must not be reported as the rename having failed. + let handle = trimmed; + try { + const me = await getMe(token); + handle = me.handle; + } catch { + // fall back to the trimmed input we just saved } + setSavedHandle(handle); + setDone(true); + setLoading(false); + onSuccess(handle); } return ( diff --git a/portal/app/account/page.tsx b/portal/app/account/page.tsx index 9fb7b2d..2908d6e 100644 --- a/portal/app/account/page.tsx +++ b/portal/app/account/page.tsx @@ -450,13 +450,15 @@ export default function AccountPage() { )}
- - {displayTier !== "free" && ( + {accountHandle && ( + + )} + {displayTier != null && displayTier !== "free" && (