diff --git a/portal/app/account/ChangeHandleModal.tsx b/portal/app/account/ChangeHandleModal.tsx new file mode 100644 index 0000000..d149426 --- /dev/null +++ b/portal/app/account/ChangeHandleModal.tsx @@ -0,0 +1,105 @@ +"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().replace(/^@+/, ""); + 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); + } 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."); + } + 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 ( +
+
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 9df989f..2908d6e 100644 --- a/portal/app/account/page.tsx +++ b/portal/app/account/page.tsx @@ -3,9 +3,10 @@ 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"; +import ChangeHandleModal from "./ChangeHandleModal"; const TRIAL_EXPIRED_MODAL_KEY = "voltius_trial_expired_shown"; const DOWNLOAD_URL = "https://voltius.app#download"; @@ -130,11 +131,9 @@ 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(""); + const [accountHandle, setAccountHandle] = useState(""); + const [showChangeHandle, setShowChangeHandle] = useState(false); + const [handleCopied, setHandleCopied] = useState(false); useEffect(() => { // Ingest ?token= from desktop app handoff (Bug 7) @@ -194,7 +193,7 @@ export default function AccountPage() { const me = await getMe(activeToken); setAccountId(me.account_id); setAccountEmail(me.email); - setAccountDisplayName(me.display_name ?? ""); + setAccountHandle(me.handle); } catch { /* non-critical */ } // Trial expired modal @@ -315,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"); @@ -380,6 +388,18 @@ export default function AccountPage() { /> )} + {showChangeHandle && token && ( + setShowChangeHandle(false)} + onSuccess={(newHandle) => { + setAccountHandle(newHandle); + setShowChangeHandle(false); + }} + /> + )} + {showChangePassword && token && accountId && (

— account

- {/* Display name row */} + {/* Handle 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" - /> - - +

Handle

+

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

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

Custom handles are a Pro feature.

+

Others can still reach you at this handle.

- ) : ( -

{accountDisplayName || "—"}

)} - {displayNameError &&

{displayNameError}

}
- {!editingDisplayName && ( - - )} +
+ {accountHandle && ( + + )} + {displayTier != null && displayTier !== "free" && ( + + )} +
{/* Email + password row */}
diff --git a/portal/lib/api.ts b/portal/lib/api.ts index 2fd5ade..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 { @@ -90,18 +92,18 @@ export function getMe(token: string): Promise { return request("/v1/auth/me", {}, token); } -export function updateDisplayName(displayName: string, token: string): Promise { +export function updateEmail(newEmail: string, authKey: string, token: string): Promise { return request( - "/v1/auth/display-name", - { method: "PUT", body: JSON.stringify({ display_name: displayName }) }, + "/v1/auth/email", + { method: "PUT", body: JSON.stringify({ new_email: newEmail, auth_key: authKey }) }, token, ); } -export function updateEmail(newEmail: string, authKey: string, token: string): Promise { +export function updateHandle(handle: string, token: string): Promise { return request( - "/v1/auth/email", - { method: "PUT", body: JSON.stringify({ new_email: newEmail, auth_key: authKey }) }, + "/v1/users/me/handle", + { method: "PUT", body: JSON.stringify({ handle }) }, token, ); }