feat: welcome desk auth and operator password reset
CI / Test (push) Canceled after 0s
CI / Build and push (push) Canceled after 0s

First visit lands on /welcome instead of burying signup in Settings.
Add a host CLI to reset passwords without a session, plus Settings
change-password. Refresh as-built design docs and the Unraid operator guide.
This commit is contained in:
Investor Flow Build
2026-08-19 21:19:57 -04:00
parent 90e1829d39
commit 7649eaf399
23 changed files with 1692 additions and 658 deletions
@@ -43,6 +43,8 @@ test("panel/page/client source pass the Primary-Rule lint", () => {
"../lib/strings.ts",
"../lib/trpc.ts",
"../stores/active-symbol-store.ts",
"../app/welcome/page.tsx",
"../components/auth/AuthDesk.tsx",
"../components/dealer-flow/DealerFlowView.tsx",
"../components/dealer-flow/DealerHeatmap.tsx",
"../components/dealer-flow/DealerLevelsStrip.tsx",
+32
View File
@@ -1,6 +1,8 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { LayoutShell } from "@/components/LayoutShell";
import { api, type AuthUser } from "@/lib/trpc";
import { useFeatureAccess } from "@/lib/useFeatureAccess";
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
import type { NavItemId } from "@/lib/workspace-profile";
@@ -39,6 +41,27 @@ const LINKS: MoreLink[] = [
];
export default function MorePage() {
const [user, setUser] = useState<AuthUser | null>(null);
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
let cancelled = false;
api.auth
.me()
.then((u) => {
if (!cancelled) setUser(u);
})
.catch(() => {
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setAuthReady(true);
});
return () => {
cancelled = true;
};
}, []);
const { modules } = useFeatureAccess();
const { isNavVisible, density } = useWorkspaceProfile();
@@ -53,6 +76,15 @@ export default function MorePage() {
return (
<LayoutShell>
<div className="space-y-6 max-w-lg mx-auto">
{authReady && !user && (
<Link
href="/welcome?mode=signup"
className="block rounded-xl border border-line-strong bg-accent/10 px-4 py-3.5 min-h-[56px] text-sm font-medium text-fg hover:bg-accent/20 transition-colors"
>
Sign in or create an account
</Link>
)}
<div>
<h1 className="text-xl font-bold text-fg">More</h1>
<p className="text-sm text-fg-faint mt-1">
+38 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { LayoutShell } from "@/components/LayoutShell";
import { OverviewPanel } from "@/components/OverviewPanel";
import { AnalystRatings } from "@/components/AnalystRatings";
@@ -16,6 +17,8 @@ import { ManagementHomeStrip } from "@/components/ManagementHomeStrip";
import { FundHoldingsStrip } from "@/components/FundHoldingsStrip";
import { DealerLevelsStrip } from "@/components/dealer-flow/DealerLevelsStrip";
import { scrollAppMainToTopSoon } from "@/lib/scroll-app-main";
import { hasSeenWelcome } from "@/lib/welcome-seen";
import { api } from "@/lib/trpc";
/** Anchor for sidebar "Symbol: TICKER" and mobile Research tab. */
export const SYMBOL_WORKBENCH_ID = "symbol-workbench";
@@ -26,9 +29,35 @@ export const SYMBOL_WORKBENCH_ID = "symbol-workbench";
* ticker, not mid-page under the book strip.
*/
export default function Page() {
const router = useRouter();
const activeSymbol = useActiveSymbolGuarded();
const { overview } = useWorkspaceProfile();
const [gate, setGate] = useState<"checking" | "ok">("checking");
// First visit: unsigned + no skip flag -> /welcome. Hold a spinner so the
// workbench does not flash. Skip (iflow-welcome-seen) or a session stays here.
useEffect(() => {
if (hasSeenWelcome()) {
setGate("ok");
return;
}
let cancelled = false;
api.auth
.me()
.then((me) => {
if (cancelled) return;
if (!me) router.replace("/welcome");
else setGate("ok");
})
.catch(() => {
if (!cancelled) router.replace("/welcome");
});
return () => {
cancelled = true;
};
}, [router]);
// Cross-page entry (Dealer Flow → Symbol): remount alone is not enough — Next
// focus / delayed layout can leave the nested main scroller mid-pane.
useEffect(() => {
@@ -45,6 +74,14 @@ export default function Page() {
}
}, [activeSymbol]);
if (gate !== "ok") {
return (
<div className="h-dvh w-full bg-surface flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-accent/40 border-t-accent animate-spin" />
</div>
);
}
return (
<LayoutShell>
<div id={SYMBOL_WORKBENCH_ID} className="space-y-4 sm:space-y-6 scroll-mt-2">
+94 -395
View File
@@ -3,23 +3,77 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { LayoutShell } from "@/components/LayoutShell";
import { api, type AuthUser } from "@/lib/trpc";
import {
EXPERIENCE_LABELS,
DENSITY_LABELS,
GOAL_LABELS,
HORIZON_LABELS,
type Density,
type ExperienceStage,
type Goal,
type Horizon,
type JargonComfort,
} from "@/lib/workspace-profile";
import { UI_STRINGS } from "@/lib/strings";
import WorkspaceInterview, { WorkspaceProfileEditor } from "@/components/auth/WorkspaceInterview";
/**
* Settings — account, workspace profile (density), and first-run interview.
* Interview configures the management workspace; it is not a course enrollment.
*/
function ChangePasswordForm() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
return (
<form
className="space-y-2 pt-2 border-t border-line"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setErr(null);
setMsg(null);
try {
await api.auth.changePassword(current, next);
setCurrent("");
setNext("");
setMsg(UI_STRINGS.changePasswordDone);
} catch (e) {
setErr(e instanceof Error ? e.message.replace(/^tRPC.*?:\s*/, "") : "request failed");
} finally {
setBusy(false);
}
}}
>
<h3 className="text-sm font-medium text-fg">{UI_STRINGS.changePasswordTitle}</h3>
<label className="block space-y-1">
<span className="text-xs text-fg-faint">{UI_STRINGS.changePasswordCurrent}</span>
<input
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
className="w-full min-h-[44px] bg-surface border border-line-strong rounded px-3 text-sm text-fg"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-fg-faint">{UI_STRINGS.changePasswordNext}</span>
<input
type="password"
autoComplete="new-password"
minLength={8}
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder="At least 8 characters"
className="w-full min-h-[44px] bg-surface border border-line-strong rounded px-3 text-sm text-fg"
/>
</label>
{err && <p className="text-xs text-danger">{err}</p>}
{msg && <p className="text-xs text-up">{msg}</p>}
<button
type="submit"
disabled={busy || !current || next.length < 8}
className="min-h-[44px] px-3 rounded-lg bg-accent text-white text-sm disabled:opacity-50"
>
{UI_STRINGS.changePasswordSave}
</button>
</form>
);
}
function SecretDisplay({ value, label }: { value: string; label: string }) {
const [revealed, setRevealed] = useState(false);
return (
@@ -196,44 +250,44 @@ function LlmEndpointSettings() {
}
export default function SettingsPage() {
const router = useRouter();
const [user, setUser] = useState<AuthUser | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
const [code, setCode] = useState("");
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const refreshUser = () =>
api.auth.me().then(setUser).catch(() => setUser(null));
useEffect(() => {
let cancelled = false;
api.auth.me().then((u) => { if (!cancelled) setUser(u); }).catch(() => {});
api.auth.me()
.then((u) => {
if (cancelled) return;
setUser(u);
setLoading(false);
if (!u) router.replace("/welcome?mode=signin");
})
.catch(() => {
if (cancelled) return;
setUser(null);
setLoading(false);
router.replace("/welcome?mode=signin");
});
return () => { cancelled = true; };
}, []);
const submit = async (mode: "signup" | "login") => {
setBusy(true); setError(null);
try {
if (mode === "signup") {
const data = await api.auth.signup(email, password) as { userId?: string; pending?: boolean; message?: string };
if (data && "pending" in data && (data as { pending?: boolean }).pending) {
setPendingMessage((data as { message?: string }).message ?? "Account created. Awaiting admin approval.");
return;
}
} else {
await api.auth.login(email, password);
}
await refreshUser();
} catch (e) {
setError(e instanceof Error ? e.message : "request failed");
} finally {
setBusy(false);
}
};
if (loading) {
return (
<LayoutShell>
<div className="h-64 flex items-center justify-center">
<div className="w-5 h-5 rounded-full border-2 border-accent/40 border-t-accent animate-spin" />
</div>
</LayoutShell>
);
}
return (
<LayoutShell>
@@ -254,6 +308,8 @@ export default function SettingsPage() {
</p>
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!enroll && !twoFactorMsg && (
<button
onClick={async () => {
@@ -306,12 +362,15 @@ export default function SettingsPage() {
{twoFactorMsg && <p className="text-xs text-up">{twoFactorMsg}</p>}
<ChangePasswordForm />
<button
onClick={async () => {
await api.auth.logout();
setUser(null);
setEnroll(null);
setTwoFactorMsg(null);
router.replace("/welcome?mode=signin");
}}
className="text-xs px-3 py-1.5 rounded border border-line-strong text-fg-muted hover:border-danger hover:text-danger"
>
@@ -322,53 +381,6 @@ export default function SettingsPage() {
{user && <LlmEndpointSettings />}
{!user && (
<section className="rounded-xl border border-line-strong bg-surface-raised p-5">
<p className="text-sm text-fg-muted mb-3">
Create an account to track symbols, holdings, and risk in one place.
</p>
{pendingMessage ? (
<div className="rounded border border-warn/40 bg-warn/5 p-3 text-sm text-fg-muted">{pendingMessage}</div>
) : (
<>
<div className="grid grid-cols-2 gap-2 mb-2">
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
type="email"
className="bg-surface border border-line-strong rounded px-2 py-2 text-sm text-fg"
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
type="password"
className="bg-surface border border-line-strong rounded px-2 py-2 text-sm text-fg"
/>
</div>
{error && <p className="text-xs text-danger mb-2">{error}</p>}
<div className="flex gap-2">
<button
disabled={busy}
onClick={() => submit("signup")}
className="px-3 py-1.5 rounded text-sm bg-accent text-white disabled:opacity-50"
>
Create account
</button>
<button
disabled={busy}
onClick={() => submit("login")}
className="px-3 py-1.5 rounded text-sm border border-line-strong text-fg disabled:opacity-50"
>
Sign in
</button>
</div>
</>
)}
</section>
)}
{user && !user.onboarded && (
<WorkspaceInterview
onDone={async () => {
@@ -389,316 +401,3 @@ export default function SettingsPage() {
</LayoutShell>
);
}
function ChoiceButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left rounded-lg border px-4 py-3 text-sm transition-colors ${
active
? "border-accent bg-accent/10 text-fg"
: "border-line bg-surface text-fg hover:bg-surface-sunken"
}`}
>
{children}
</button>
);
}
function WorkspaceInterview({ onDone }: { onDone: () => void | Promise<void> }) {
const router = useRouter();
const [step, setStep] = useState(0);
const [experienceStage, setExperienceStage] = useState<ExperienceStage | "">("");
const [goal, setGoal] = useState<Goal | "">("");
const [horizon, setHorizon] = useState<Horizon | "">("");
const [jargonComfort, setJargonComfort] = useState<JargonComfort>("plain");
const [starter, setStarter] = useState<{
watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>;
disclaimer: string;
density: string;
} | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!experienceStage) return;
api.onboarding
.starter({ experienceStage })
.then(setStarter)
.catch(() => setStarter(null));
}, [experienceStage]);
const finish = async () => {
if (!experienceStage || !goal || !horizon) return;
setBusy(true);
setErr(null);
try {
await api.onboarding.complete({
experienceStage,
goal,
horizon,
jargonComfort,
});
window.dispatchEvent(new Event("workspace-profile-changed"));
await onDone();
router.push("/portfolio");
} catch (e) {
setErr(e instanceof Error ? e.message : "Setup failed");
} finally {
setBusy(false);
}
};
return (
<section className="rounded-xl border-2 border-accent/40 bg-surface-raised p-5 space-y-4">
<div>
<h2 className="text-base font-semibold text-fg">Set up your workspace</h2>
<p className="text-xs text-fg-faint mt-1">
A few questions so we can size risk defaults and how much detail to show. You can change this anytime.
</p>
<p className="text-[10px] text-fg-muted mt-2">Step {Math.min(step + 1, 4)} of 4</p>
</div>
{step === 0 && (
<div className="space-y-2">
<p className="text-sm text-fg">How much experience do you have with stocks or funds?</p>
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
<ChoiceButton
key={k}
active={experienceStage === k}
onClick={() => {
setExperienceStage(k);
setStep(1);
}}
>
{EXPERIENCE_LABELS[k]}
</ChoiceButton>
))}
</div>
)}
{step === 1 && (
<div className="space-y-2">
<p className="text-sm text-fg">What matters most for money you will track here?</p>
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
<ChoiceButton
key={k}
active={goal === k}
onClick={() => {
setGoal(k);
setStep(2);
}}
>
{GOAL_LABELS[k]}
</ChoiceButton>
))}
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(0)}>
Back
</button>
</div>
)}
{step === 2 && (
<div className="space-y-2">
<p className="text-sm text-fg">How long until you may need this money?</p>
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
<ChoiceButton
key={k}
active={horizon === k}
onClick={() => {
setHorizon(k);
setStep(3);
}}
>
{HORIZON_LABELS[k]}
</ChoiceButton>
))}
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(1)}>
Back
</button>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm text-fg">How do you prefer labels and terms?</p>
{(
[
{ k: "plain" as const, l: "Everyday language first" },
{ k: "mixed" as const, l: "Mix of plain and market terms" },
{ k: "technical" as const, l: "Full market terms" },
] as const
).map(({ k, l }) => (
<ChoiceButton key={k} active={jargonComfort === k} onClick={() => setJargonComfort(k)}>
{l}
</ChoiceButton>
))}
</div>
{starter && (
<div>
<p className="text-xs text-fg-faint mb-1">
Starter symbols to track (examples for the product — not recommendations). Density:{" "}
<span className="capitalize">{starter.density}</span>
</p>
<ul className="text-xs space-y-1 max-h-40 overflow-y-auto">
{starter.watchlist.map((s) => (
<li key={s.symbol} className="flex gap-2">
<span className="font-mono text-accent w-12 shrink-0">{s.symbol}</span>
<span className="text-fg-muted">{s.reason}</span>
</li>
))}
</ul>
</div>
)}
{err && <p className="text-xs text-danger">{err}</p>}
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy || !experienceStage || !goal || !horizon}
onClick={finish}
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
>
{busy ? "Setting up…" : "Finish and open Portfolio"}
</button>
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(2)}>
Back
</button>
</div>
</div>
)}
</section>
);
}
function WorkspaceProfileEditor({
user,
onSaved,
}: {
user: AuthUser;
onSaved: () => void | Promise<void>;
}) {
const [density, setDensity] = useState<Density>((user.density as Density) || "focused");
const [experienceStage, setExperienceStage] = useState<ExperienceStage>(
(user.experienceStage as ExperienceStage) || "never_invested",
);
const [goal, setGoal] = useState<Goal>((user.goal as Goal) || "grow");
const [horizon, setHorizon] = useState<Horizon>((user.horizon as Horizon) || "long");
const [jargonComfort, setJargonComfort] = useState<JargonComfort>(
(user.jargonComfort as JargonComfort) || "plain",
);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
setDensity((user.density as Density) || "focused");
setExperienceStage((user.experienceStage as ExperienceStage) || "never_invested");
setGoal((user.goal as Goal) || "grow");
setHorizon((user.horizon as Horizon) || "long");
setJargonComfort((user.jargonComfort as JargonComfort) || "plain");
}, [user]);
const save = async () => {
setBusy(true);
setErr(null);
setMsg(null);
try {
await api.onboarding.updateProfile({
density,
experienceStage,
goal,
horizon,
jargonComfort,
});
setMsg("Workspace preferences saved.");
window.dispatchEvent(new Event("workspace-profile-changed"));
await onSaved();
} catch (e) {
setErr(e instanceof Error ? e.message : "Save failed");
} finally {
setBusy(false);
}
};
return (
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4">
<div>
<h2 className="text-base font-semibold text-fg">Workspace</h2>
<p className="text-xs text-fg-faint mt-1">
Control how much detail appears in navigation and research. Focused keeps the book, monitor, and risk front and center.
</p>
</div>
<div>
<p className="text-xs text-fg-faint mb-2">Experience</p>
<div className="flex flex-col gap-2">
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
<ChoiceButton key={k} active={experienceStage === k} onClick={() => setExperienceStage(k)}>
{EXPERIENCE_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
<div>
<p className="text-xs text-fg-faint mb-2">Workspace density</p>
<div className="flex flex-col gap-2">
{(Object.keys(DENSITY_LABELS) as Density[]).map((k) => (
<ChoiceButton key={k} active={density === k} onClick={() => setDensity(k)}>
{DENSITY_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<p className="text-xs text-fg-faint mb-2">Goal</p>
<div className="flex flex-col gap-2">
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
<ChoiceButton key={k} active={goal === k} onClick={() => setGoal(k)}>
{GOAL_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
<div>
<p className="text-xs text-fg-faint mb-2">Horizon</p>
<div className="flex flex-col gap-2">
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
<ChoiceButton key={k} active={horizon === k} onClick={() => setHorizon(k)}>
{HORIZON_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
</div>
{err && <p className="text-xs text-danger">{err}</p>}
{msg && <p className="text-xs text-up">{msg}</p>}
<button
type="button"
disabled={busy}
onClick={save}
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
>
{busy ? "Saving…" : "Save workspace"}
</button>
</section>
);
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { Suspense } from "react";
import AuthDesk from "@/components/auth/AuthDesk";
function WelcomeFallback() {
return <div className="h-dvh w-full bg-surface" />;
}
export default function WelcomePage() {
return (
<Suspense fallback={<WelcomeFallback />}>
<AuthDesk />
</Suspense>
);
}
+1 -1
View File
@@ -168,7 +168,7 @@ export function OverviewPanel() {
)}
{sector && (
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.sectorLabel}</dt><dd className="text-fg">{sector.sector ?? "—"}</dd></div>
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.industryLabel}</dt><dd className="text-fg">{sector.industry ?? "—"}</dd></div>
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.tickerKindLabel}</dt><dd className="text-fg">{sector.tickerKind}</dd></div>
+1 -1
View File
@@ -104,7 +104,7 @@ const SECTIONS: NavSection[] = [
title: "Settings",
icon: Settings,
module: "settings",
items: [{ label: "Profile / Auth", href: "/settings", navId: "settings" }],
items: [{ label: "Settings", href: "/settings", navId: "settings" }],
},
];
+13 -3
View File
@@ -12,6 +12,8 @@ import {
} from "@/components/MobileWatchlistSheet";
import { LastUpdated } from "@/components/shared/LastUpdated";
import { quoteStampTone } from "@/lib/lastUpdatedCopy";
import { useFeatureAccess } from "@/lib/useFeatureAccess";
import { AuthHeaderActions } from "./auth/AuthHeaderActions";
/**
* Persistent top bar showing the active symbol, price, change, and search.
@@ -24,6 +26,8 @@ export function SymbolHeader() {
const [showSearch, setShowSearch] = useState(false);
const [showLists, setShowLists] = useState(false);
const [snap, setSnap] = useState<Snapshot | null>(null);
const { user, loading: authLoading } = useFeatureAccess();
const signedOut = !authLoading && !user;
useEffect(() => {
let cancelled = false;
@@ -84,7 +88,7 @@ export function SymbolHeader() {
return (
<header className="sticky top-0 z-50 shrink-0 border-b border-line bg-surface-raised/95 backdrop-blur-sm safe-area-px">
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 py-2 sm:py-3 min-h-[48px]">
{/* Left: Logo mark */}
{/* Left: Logo mark. Mobile Sign in sits here so it does not crowd ticker tools. */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-8 h-8 sm:w-7 sm:h-7 rounded-lg bg-accent flex items-center justify-center">
<span className="text-xs font-bold text-surface">IF</span>
@@ -92,6 +96,9 @@ export function SymbolHeader() {
<h1 className="hidden sm:block text-sm font-semibold text-fg tracking-tight">
Investor Flow
</h1>
<div className="sm:hidden">
<AuthHeaderActions />
</div>
</div>
{/* Center/right: ticker + actions */}
@@ -119,8 +126,8 @@ export function SymbolHeader() {
)}
</div>
{/* Mobile: compact ticker */}
<div className="sm:hidden flex flex-col items-end min-w-0 mr-1">
{/* Mobile: compact ticker. Hidden when signed out so the Sign in control has room. */}
<div className={`sm:hidden flex-col items-end min-w-0 mr-1 ${signedOut ? "hidden" : "flex"}`}>
<div className="flex items-center gap-1.5 text-xs">
<span className="font-bold text-fg font-mono">{symbol}</span>
{priceLabel && (
@@ -191,6 +198,9 @@ export function SymbolHeader() {
<Search className="h-5 w-5" />
</button>
<div className="hidden sm:block">
<AuthHeaderActions />
</div>
<NotificationBell />
<ThemeSwitcher />
</div>
+301
View File
@@ -0,0 +1,301 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api } from "@/lib/trpc";
import { markWelcomeSeen } from "@/lib/welcome-seen";
import { UI_STRINGS } from "@/lib/strings";
import WorkspaceInterview from "./WorkspaceInterview";
type Mode = "signup" | "signin";
function parseMode(raw: string | null): Mode {
return raw === "signin" ? "signin" : "signup";
}
function errorText(e: unknown): string {
return e instanceof Error ? e.message : "request failed";
}
function ForgotPasswordHint() {
const [open, setOpen] = useState(false);
return (
<div className="text-center">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-xs text-fg-muted hover:text-fg underline decoration-dashed"
>
{UI_STRINGS.welcomeForgotPassword}
</button>
{open && (
<p className="mt-2 text-left text-xs text-fg-faint leading-relaxed">
{UI_STRINGS.welcomeForgotPasswordBody}
</p>
)}
</div>
);
}
export default function AuthDesk() {
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState<Mode>(() => parseMode(searchParams.get("mode")));
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const [showTotp, setShowTotp] = useState(false);
const [showInterview, setShowInterview] = useState(false);
useEffect(() => {
setMode(parseMode(searchParams.get("mode")));
}, [searchParams]);
useEffect(() => {
let cancelled = false;
api.auth
.me()
.then((me) => {
if (cancelled || !me) return;
if (me.onboarded) {
markWelcomeSeen();
router.replace("/");
return;
}
setShowInterview(true);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [router]);
const switchMode = (next: Mode) => {
setMode(next);
setError(null);
setPendingMessage(null);
setShowTotp(false);
setTotpCode("");
if (next === "signup") setPassword("");
router.replace(next === "signin" ? "/welcome?mode=signin" : "/welcome?mode=signup");
};
const afterSession = async () => {
const me = await api.auth.me();
if (me && !me.onboarded) {
setShowInterview(true);
return;
}
markWelcomeSeen();
router.replace("/");
};
const submit = async () => {
setError(null);
setBusy(true);
try {
if (mode === "signup") {
const data = (await api.auth.signup(email, password)) as {
pending?: boolean;
message?: string;
};
if (data?.pending) {
setPendingMessage(data.message ?? UI_STRINGS.welcomePendingBody);
return;
}
await afterSession();
return;
}
await api.auth.login(email, password, showTotp ? totpCode.trim() : undefined);
await afterSession();
} catch (e) {
const msg = errorText(e);
if (/pending admin approval/i.test(msg)) {
setPendingMessage(msg.replace(/^tRPC.*?:\s*/, "").trim() || UI_STRINGS.welcomePendingBody);
return;
}
if (/two[- ]?factor|totp/i.test(msg)) {
setShowTotp(true);
setError(msg.replace(/^tRPC.*?:\s*/, "").trim() || "Two-factor code required or invalid.");
return;
}
setError(msg.replace(/^tRPC.*?:\s*/, "").trim() || msg);
} finally {
setBusy(false);
}
};
const handleSkip = () => {
markWelcomeSeen();
router.replace("/");
};
if (showInterview) {
return (
<div className="h-dvh w-full bg-surface text-fg flex items-center justify-center p-4">
<div className="w-full max-w-md">
<WorkspaceInterview
onDone={async () => {
markWelcomeSeen();
}}
/>
</div>
</div>
);
}
return (
<div className="h-dvh w-full bg-surface text-fg flex items-center justify-center p-4">
<div className="w-full max-w-md border border-line-strong bg-surface-raised rounded-xl p-6 space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
<span className="text-xs font-bold text-surface">IF</span>
</div>
<p className="text-sm font-semibold tracking-tight">{UI_STRINGS.welcomeEyebrow}</p>
</div>
<div className="flex border-b border-line-strong">
<button
type="button"
onClick={() => switchMode("signup")}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
mode === "signup" ? "border-accent text-fg" : "border-transparent text-fg-muted hover:text-fg"
}`}
>
{UI_STRINGS.welcomeCreateAccount}
</button>
<button
type="button"
onClick={() => switchMode("signin")}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
mode === "signin" ? "border-accent text-fg" : "border-transparent text-fg-muted hover:text-fg"
}`}
>
{UI_STRINGS.welcomeSignIn}
</button>
</div>
{pendingMessage ? (
<section className="rounded-lg border border-warn/40 bg-warn/5 p-4 space-y-3">
<h2 className="text-sm font-semibold text-fg">{UI_STRINGS.welcomePendingTitle}</h2>
<p className="text-xs text-fg-muted">{pendingMessage}</p>
<button
type="button"
onClick={() => switchMode("signin")}
className="text-xs text-accent hover:text-fg"
>
{UI_STRINGS.welcomeBackToSignIn}
</button>
</section>
) : showTotp ? (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<div>
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.welcomeTotpTitle}</h2>
<p className="text-xs text-fg-faint mt-1">{UI_STRINGS.welcomeTotpHint}</p>
</div>
<label className="block space-y-1">
<span className="text-xs text-fg-faint">{UI_STRINGS.twoFactorCodePlaceholder}</span>
<input
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
type="text"
inputMode="numeric"
maxLength={6}
autoComplete="one-time-code"
autoFocus
placeholder="000000"
className="w-full min-h-[44px] bg-surface border border-line-strong rounded-lg px-3 text-sm font-mono tabular-nums tracking-widest text-center text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
/>
</label>
{error && <p className="text-xs text-danger">{error}</p>}
<button
type="submit"
disabled={busy || totpCode.length !== 6}
className="w-full min-h-[44px] rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50 hover:bg-accent-hover transition-colors"
>
{busy ? "Working..." : UI_STRINGS.welcomeContinue}
</button>
<p className="text-center">
<button
type="button"
onClick={() => {
setShowTotp(false);
setTotpCode("");
setError(null);
}}
className="text-xs text-fg-muted hover:text-fg underline decoration-dashed"
>
{UI_STRINGS.welcomeBackToSignIn}
</button>
</p>
</form>
) : (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<div>
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.welcomeTitle}</h2>
<p className="text-xs text-fg-faint mt-1">{UI_STRINGS.welcomeBody}</p>
</div>
<label className="block space-y-1">
<span className="text-xs text-fg-faint">{UI_STRINGS.authEmailLabel}</span>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
required
autoComplete={mode === "signup" ? "email" : "username"}
placeholder="you@example.com"
className="w-full min-h-[44px] bg-surface border border-line-strong rounded-lg px-3 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-fg-faint">{UI_STRINGS.authPasswordLabel}</span>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
required
minLength={mode === "signup" ? 8 : undefined}
autoComplete={mode === "signup" ? "new-password" : "current-password"}
placeholder={mode === "signup" ? "At least 8 characters" : "Password"}
className="w-full min-h-[44px] bg-surface border border-line-strong rounded-lg px-3 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
/>
</label>
{error && <p className="text-xs text-danger">{error}</p>}
<button
type="submit"
disabled={busy || !email.trim() || (mode === "signup" ? password.length < 8 : !password)}
className="w-full min-h-[44px] rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50 hover:bg-accent-hover transition-colors"
>
{busy ? "Working..." : UI_STRINGS.welcomeContinue}
</button>
{mode === "signin" && <ForgotPasswordHint />}
<p className="text-center">
<button
type="button"
onClick={handleSkip}
className="text-xs text-fg-muted hover:text-fg underline decoration-dashed"
>
{UI_STRINGS.welcomeSkip}
</button>
</p>
</form>
)}
</div>
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { LogIn } from "lucide-react";
import { api, type AuthUser } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings";
/**
* Header CTAs for the signed-out state.
* Hidden until auth.me settles so signed-in users never flash Sign in.
*/
export function AuthHeaderActions() {
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
api.auth
.me()
.then((u) => {
if (!cancelled) setUser(u);
})
.catch(() => {
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, []);
if (!ready || user) return null;
return (
<>
<div className="hidden sm:flex items-center gap-2">
<Link
href="/welcome?mode=signin"
className="inline-flex items-center h-8 px-2.5 rounded-lg text-xs font-medium text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors"
>
{UI_STRINGS.welcomeSignIn}
</Link>
<Link
href="/welcome?mode=signup"
className="inline-flex items-center h-8 px-2.5 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover transition-colors"
>
{UI_STRINGS.welcomeCreateAccount}
</Link>
</div>
<Link
href="/welcome?mode=signin"
aria-label={UI_STRINGS.welcomeSignIn}
className="sm:hidden inline-flex items-center justify-center min-h-[44px] min-w-[44px] shrink-0 rounded-lg text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors"
>
<LogIn className="h-5 w-5" />
</Link>
</>
);
}
@@ -0,0 +1,332 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api } from "@/lib/trpc";
import {
EXPERIENCE_LABELS,
DENSITY_LABELS,
GOAL_LABELS,
HORIZON_LABELS,
type Density,
type ExperienceStage,
type Goal,
type Horizon,
type JargonComfort,
} from "@/lib/workspace-profile";
export function ChoiceButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left rounded-lg border px-4 py-3 text-sm transition-colors ${
active
? "border-accent bg-accent/10 text-fg"
: "border-line bg-surface text-fg hover:bg-surface-sunken"
}`}
>
{children}
</button>
);
}
export default function WorkspaceInterview({ onDone }: { onDone: () => void | Promise<void> }) {
const router = useRouter();
const [step, setStep] = useState(0);
const [experienceStage, setExperienceStage] = useState<ExperienceStage | "">("");
const [goal, setGoal] = useState<Goal | "">("");
const [horizon, setHorizon] = useState<Horizon | "">("");
const [jargonComfort, setJargonComfort] = useState<JargonComfort>("plain");
const [starter, setStarter] = useState<{
watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>;
disclaimer: string;
density: string;
} | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!experienceStage) return;
api.onboarding
.starter({ experienceStage })
.then(setStarter)
.catch(() => setStarter(null));
}, [experienceStage]);
const finish = async () => {
if (!experienceStage || !goal || !horizon) return;
setBusy(true);
setErr(null);
try {
await api.onboarding.complete({
experienceStage,
goal,
horizon,
jargonComfort,
});
window.dispatchEvent(new Event("workspace-profile-changed"));
await onDone();
router.push("/portfolio");
} catch (e) {
setErr(e instanceof Error ? e.message : "Setup failed");
} finally {
setBusy(false);
}
};
return (
<section className="rounded-xl border-2 border-accent/40 bg-surface-raised p-5 space-y-4">
<div>
<h2 className="text-base font-semibold text-fg">Set up your workspace</h2>
<p className="text-xs text-fg-faint mt-1">
A few questions so we can size risk defaults and how much detail to show. You can change this anytime.
</p>
<p className="text-[10px] text-fg-muted mt-2">Step {Math.min(step + 1, 4)} of 4</p>
</div>
{step === 0 && (
<div className="space-y-2">
<p className="text-sm text-fg">How much experience do you have with stocks or funds?</p>
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
<ChoiceButton
key={k}
active={experienceStage === k}
onClick={() => {
setExperienceStage(k);
setStep(1);
}}
>
{EXPERIENCE_LABELS[k]}
</ChoiceButton>
))}
</div>
)}
{step === 1 && (
<div className="space-y-2">
<p className="text-sm text-fg">What matters most for money you will track here?</p>
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
<ChoiceButton
key={k}
active={goal === k}
onClick={() => {
setGoal(k);
setStep(2);
}}
>
{GOAL_LABELS[k]}
</ChoiceButton>
))}
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(0)}>
Back
</button>
</div>
)}
{step === 2 && (
<div className="space-y-2">
<p className="text-sm text-fg">How long until you may need this money?</p>
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
<ChoiceButton
key={k}
active={horizon === k}
onClick={() => {
setHorizon(k);
setStep(3);
}}
>
{HORIZON_LABELS[k]}
</ChoiceButton>
))}
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(1)}>
Back
</button>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm text-fg">How do you prefer labels and terms?</p>
{(
[
{ k: "plain" as const, l: "Everyday language first" },
{ k: "mixed" as const, l: "Mix of plain and market terms" },
{ k: "technical" as const, l: "Full market terms" },
] as const
).map(({ k, l }) => (
<ChoiceButton key={k} active={jargonComfort === k} onClick={() => setJargonComfort(k)}>
{l}
</ChoiceButton>
))}
</div>
{starter && (
<div>
<p className="text-xs text-fg-faint mb-1">
Starter symbols to track (examples for the product - not recommendations). Density:{" "}
<span className="capitalize">{starter.density}</span>
</p>
<ul className="text-xs space-y-1 max-h-40 overflow-y-auto">
{starter.watchlist.map((s) => (
<li key={s.symbol} className="flex gap-2">
<span className="font-mono text-accent w-12 shrink-0">{s.symbol}</span>
<span className="text-fg-muted">{s.reason}</span>
</li>
))}
</ul>
</div>
)}
{err && <p className="text-xs text-danger">{err}</p>}
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy || !experienceStage || !goal || !horizon}
onClick={finish}
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
>
{busy ? "Setting up…" : "Finish and open Portfolio"}
</button>
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(2)}>
Back
</button>
</div>
</div>
)}
</section>
);
}
// Kept for the workspace profile editor section that still lives in Settings.
function WorkspaceProfileEditor({
user,
onSaved,
}: {
user: import("@/lib/trpc").AuthUser;
onSaved: () => void | Promise<void>;
}) {
const [density, setDensity] = useState<Density>((user.density as Density) || "focused");
const [experienceStage, setExperienceStage] = useState<ExperienceStage>(
(user.experienceStage as ExperienceStage) || "never_invested",
);
const [goal, setGoal] = useState<Goal>((user.goal as Goal) || "grow");
const [horizon, setHorizon] = useState<Horizon>((user.horizon as Horizon) || "long");
const [jargonComfort, setJargonComfort] = useState<JargonComfort>(
(user.jargonComfort as JargonComfort) || "plain",
);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
setDensity((user.density as Density) || "focused");
setExperienceStage((user.experienceStage as ExperienceStage) || "never_invested");
setGoal((user.goal as Goal) || "grow");
setHorizon((user.horizon as Horizon) || "long");
setJargonComfort((user.jargonComfort as JargonComfort) || "plain");
}, [user]);
const save = async () => {
setBusy(true);
setErr(null);
setMsg(null);
try {
await api.onboarding.updateProfile({
density,
experienceStage,
goal,
horizon,
jargonComfort,
});
setMsg("Workspace preferences saved.");
window.dispatchEvent(new Event("workspace-profile-changed"));
await onSaved();
} catch (e) {
setErr(e instanceof Error ? e.message : "Save failed");
} finally {
setBusy(false);
}
};
return (
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4">
<div>
<h2 className="text-base font-semibold text-fg">Workspace</h2>
<p className="text-xs text-fg-faint mt-1">
Control how much detail appears in navigation and research. Focused keeps the book, monitor, and risk front and center.
</p>
</div>
<div>
<p className="text-xs text-fg-faint mb-2">Experience</p>
<div className="flex flex-col gap-2">
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
<ChoiceButton key={k} active={experienceStage === k} onClick={() => setExperienceStage(k)}>
{EXPERIENCE_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
<div>
<p className="text-xs text-fg-faint mb-2">Workspace density</p>
<div className="flex flex-col gap-2">
{(Object.keys(DENSITY_LABELS) as Density[]).map((k) => (
<ChoiceButton key={k} active={density === k} onClick={() => setDensity(k)}>
{DENSITY_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<p className="text-xs text-fg-faint mb-2">Goal</p>
<div className="flex flex-col gap-2">
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
<ChoiceButton key={k} active={goal === k} onClick={() => setGoal(k)}>
{GOAL_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
<div>
<p className="text-xs text-fg-faint mb-2">Horizon</p>
<div className="flex flex-col gap-2">
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
<ChoiceButton key={k} active={horizon === k} onClick={() => setHorizon(k)}>
{HORIZON_LABELS[k]}
</ChoiceButton>
))}
</div>
</div>
</div>
{err && <p className="text-xs text-danger">{err}</p>}
{msg && <p className="text-xs text-up">{msg}</p>}
<button
type="button"
disabled={busy}
onClick={save}
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
>
{busy ? "Saving…" : "Save workspace"}
</button>
</section>
);
}
// Re-export WorkspaceProfileEditor so Settings can keep using it from the new file.
export { WorkspaceProfileEditor };
+22
View File
@@ -43,6 +43,28 @@ export const UI_STRINGS = {
completeOnboardingButton: "Finish setup",
onboardingDone: "Setup complete — your starter watchlist is tracking.",
// Welcome desk (first-user signup / signin)
welcomeEyebrow: "Investor Flow",
welcomeTitle: "Research and book in one place.",
welcomeBody: "Create an account, then wait for approval. Educational observation only.",
welcomeContinue: "Continue",
welcomeSkip: "Continue without an account",
welcomePendingTitle: "Account created",
welcomePendingBody: "An admin must approve your account before you can sign in.",
welcomeTotpTitle: "Two-factor code",
welcomeTotpHint: "Enter the 6-digit code from your authenticator app.",
welcomeBackToSignIn: "Back to sign in",
welcomeSignIn: "Sign in",
welcomeCreateAccount: "Create account",
welcomeForgotPassword: "Forgot password?",
welcomeForgotPasswordBody:
"There is no email reset. An admin can set a temporary password under Admin, Users. If you operate this host, reset it from the machine with the reset-password CLI (see DEPLOY_UNRAID.md).",
changePasswordTitle: "Change password",
changePasswordCurrent: "Current password",
changePasswordNext: "New password",
changePasswordSave: "Update password",
changePasswordDone: "Password updated.",
// Portfolio & Watchlist panels
portfolioTitle: "Holdings",
portfolioCaption: "Symbol, shares, and average cost.",
+2
View File
@@ -1010,6 +1010,8 @@ export const api = {
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }),
logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
changePassword: (current: string, next: string) =>
trpcMutate<{ ok: boolean }>("auth.changePassword", { current, next }),
me: () => trpcQuery<AuthUser | null>("auth.me"),
enable2fa: () => trpcMutate<{ totpSecret: string; qrUrl: string; backupCodes: string[] }>("auth.enable2fa", {}),
confirm2fa: (totp: string) => trpcMutate<{ ok: boolean }>("auth.confirm2fa", { totp }),
+29
View File
@@ -0,0 +1,29 @@
/**
* localStorage flag used to remember that a user has visited the welcome desk.
* Wrapped in try/catch so a broken or disabled storage does not break page load.
*/
export const WELCOME_SEEN_KEY = "iflow-welcome-seen";
function safeGet(): string | null {
if (typeof window === "undefined") return null;
try {
return localStorage.getItem(WELCOME_SEEN_KEY);
} catch {
return null;
}
}
export function hasSeenWelcome(): boolean {
const v = safeGet();
return v !== null && v !== "" && v !== "false";
}
export function markWelcomeSeen(): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(WELCOME_SEEN_KEY, "1");
} catch {
// storage full or private mode — non-fatal, welcome will simply re-appear next visit.
}
}