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
105 changes: 105 additions & 0 deletions portal/app/account/ChangeHandleModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl border border-[#1e1e2e] bg-[#111118] p-6" onClick={(e) => e.stopPropagation()}>
<h2 className="text-base font-bold text-white mb-1">Change handle</h2>
<p className="text-xs text-zinc-500 mb-4">Current: @{currentHandle}</p>

{done ? (
<div className="space-y-3">
<p className="text-sm text-green-400">Handle updated to <strong>@{savedHandle}</strong>.</p>
<button onClick={onClose} className="w-full py-2.5 rounded-xl bg-cyan-500 hover:bg-cyan-400 text-black font-semibold text-sm transition-colors">Done</button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<input
type="text"
placeholder="your-handle"
value={newHandle}
onChange={(e) => 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"
/>
<p className="text-xs text-zinc-600 leading-relaxed">
3-30 characters: lowercase letters, digits, hyphens, or underscores. Cannot start or end with a separator.
You can rename again in 30 days.
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2 mt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-[#1e1e2e] hover:border-zinc-600 text-zinc-400 hover:text-white text-sm transition-colors">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 py-2.5 rounded-xl bg-cyan-500 hover:bg-cyan-400 disabled:opacity-50 text-black font-semibold text-sm transition-colors">{loading ? "Saving…" : "Save"}</button>
</div>
</form>
)}
</div>
</div>
);
}
109 changes: 52 additions & 57 deletions portal/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -380,6 +388,18 @@ export default function AccountPage() {
/>
)}

{showChangeHandle && token && (
<ChangeHandleModal
currentHandle={accountHandle}
token={token}
onClose={() => setShowChangeHandle(false)}
onSuccess={(newHandle) => {
setAccountHandle(newHandle);
setShowChangeHandle(false);
}}
/>
)}

{showChangePassword && token && accountId && (
<ChangePasswordModal
accountId={accountId}
Expand Down Expand Up @@ -417,61 +437,36 @@ export default function AccountPage() {
<div className="mb-10">
<p className="font-mono text-xs text-cyan-400 mb-3">— account</p>
<div className="rounded-2xl border border-[#1e1e2e] bg-[#111118] divide-y divide-[#1e1e2e]">
{/* Display name row */}
{/* Handle row */}
<div className="p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<p className="text-xs text-zinc-500 uppercase tracking-widest mb-1">Display name</p>
{editingDisplayName ? (
<div className="flex items-center gap-2 mt-1">
<input
autoFocus
type="text"
value={displayNameInput}
maxLength={50}
onChange={(e) => { 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"
/>
<button
onClick={async () => {
const trimmed = displayNameInput.trim();
if (!trimmed) { setDisplayNameError("Cannot be empty"); return; }
setDisplayNameLoading(true);
setDisplayNameError("");
try {
await updateDisplayName(trimmed, token!);
setAccountDisplayName(trimmed);
setEditingDisplayName(false);
} catch (e) {
setDisplayNameError(e instanceof Error ? e.message : "Update failed");
} finally {
setDisplayNameLoading(false);
}
}}
disabled={displayNameLoading}
className="px-3 py-1.5 rounded-lg bg-cyan-500 hover:bg-cyan-400 disabled:opacity-50 text-black text-sm font-semibold transition-colors"
>
{displayNameLoading ? "Saving…" : "Save"}
</button>
<button
onClick={() => { setEditingDisplayName(false); setDisplayNameError(""); }}
className="px-3 py-1.5 rounded-lg border border-[#1e1e2e] hover:border-zinc-600 text-zinc-400 hover:text-white text-sm transition-colors"
>
Cancel
</button>
<p className="text-xs text-zinc-500 uppercase tracking-widest mb-1">Handle</p>
<p className="text-sm text-white">{accountHandle ? `@${accountHandle}` : "—"}</p>
{displayTier === "free" && (
<div className="mt-1">
<p className="text-xs text-zinc-500">Custom handles are a Pro feature.</p>
<p className="text-xs text-zinc-500">Others can still reach you at this handle.</p>
</div>
) : (
<p className="text-sm text-white">{accountDisplayName || "—"}</p>
)}
{displayNameError && <p className="mt-1 text-xs text-red-400">{displayNameError}</p>}
</div>
{!editingDisplayName && (
<button
onClick={() => { setDisplayNameInput(accountDisplayName); setDisplayNameError(""); setEditingDisplayName(true); }}
className="px-4 py-2 rounded-xl border border-[#1e1e2e] hover:border-zinc-600 text-zinc-400 hover:text-white text-sm font-semibold transition-colors shrink-0"
>
Edit
</button>
)}
<div className="flex gap-2 shrink-0">
{accountHandle && (
<button
onClick={() => void handleCopyHandle()}
className="px-4 py-2 rounded-xl border border-[#1e1e2e] hover:border-zinc-600 text-zinc-400 hover:text-white text-sm font-semibold transition-colors"
>
{handleCopied ? "Copied" : "Copy"}
</button>
)}
{displayTier != null && displayTier !== "free" && (
<button
onClick={() => setShowChangeHandle(true)}
className="px-4 py-2 rounded-xl border border-[#1e1e2e] hover:border-zinc-600 text-zinc-400 hover:text-white text-sm font-semibold transition-colors"
>
Change handle
</button>
)}
</div>
</div>
{/* Email + password row */}
<div className="p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
Expand Down
14 changes: 8 additions & 6 deletions portal/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,18 +92,18 @@ export function getMe(token: string): Promise<MeResponse> {
return request<MeResponse>("/v1/auth/me", {}, token);
}

export function updateDisplayName(displayName: string, token: string): Promise<void> {
export function updateEmail(newEmail: string, authKey: string, token: string): Promise<void> {
return request<void>(
"/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<void> {
export function updateHandle(handle: string, token: string): Promise<void> {
return request<void>(
"/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,
);
}
Expand Down