From 01cd1d9f71427319284579b438ad08ede56517a6 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Sat, 28 Mar 2026 14:02:07 +0800 Subject: [PATCH] feat: add forced password change page on first login The backend sets mustChangePassword=true for all new accounts and blocks API calls until the password is changed. The frontend was not handling this flag - it logged the user in and redirected to the dashboard where every API call silently failed with 403. Add a /change-password page that is shown when mustChangePassword is true. The login page now redirects there instead of home, and the AuthGuard intercepts any direct navigation to force the change first. --- apps/web/src/App.tsx | 13 +- apps/web/src/hooks/use-auth.ts | 39 +++++- apps/web/src/pages/change-password-page.tsx | 135 ++++++++++++++++++++ apps/web/src/pages/login-page.tsx | 8 +- 4 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/pages/change-password-page.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 168e1a9d..8479adaf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { useAuth } from "./hooks/use-auth"; import { AutomatePage } from "./pages/automate-page"; +import { ChangePasswordPage } from "./pages/change-password-page"; import { FilesPage } from "./pages/files-page"; import { FullscreenGridPage } from "./pages/fullscreen-grid-page"; import { HomePage } from "./pages/home-page"; @@ -54,11 +55,11 @@ class ErrorBoundary extends Component< } function AuthGuard({ children }: { children: React.ReactNode }) { - const { loading, authEnabled, isAuthenticated } = useAuth(); + const { loading, authEnabled, isAuthenticated, mustChangePassword } = useAuth(); const location = useLocation(); - // Don't guard the login page itself - if (location.pathname === "/login") { + // Don't guard the login or change-password pages + if (location.pathname === "/login" || location.pathname === "/change-password") { return <>{children}; } @@ -77,6 +78,11 @@ function AuthGuard({ children }: { children: React.ReactNode }) { return ; } + // Force password change before allowing access to the app + if (authEnabled && mustChangePassword) { + return ; + } + return <>{children}; } @@ -88,6 +94,7 @@ export function App() { } /> + } /> } /> } /> } /> diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index 9fb8a8c1..f9aa3764 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -4,6 +4,7 @@ interface AuthState { loading: boolean; authEnabled: boolean; isAuthenticated: boolean; + mustChangePassword: boolean; } export function useAuth(): AuthState { @@ -11,6 +12,7 @@ export function useAuth(): AuthState { loading: true, authEnabled: false, isAuthenticated: false, + mustChangePassword: false, }); useEffect(() => { @@ -21,14 +23,24 @@ export function useAuth(): AuthState { const config = await configRes.json(); if (!config.authEnabled) { - setState({ loading: false, authEnabled: false, isAuthenticated: true }); + setState({ + loading: false, + authEnabled: false, + isAuthenticated: true, + mustChangePassword: false, + }); return; } // Auth is enabled — check if we have a valid session const token = localStorage.getItem("stirling-token"); if (!token) { - setState({ loading: false, authEnabled: true, isAuthenticated: false }); + setState({ + loading: false, + authEnabled: true, + isAuthenticated: false, + mustChangePassword: false, + }); return; } @@ -37,14 +49,31 @@ export function useAuth(): AuthState { }); if (sessionRes.ok) { - setState({ loading: false, authEnabled: true, isAuthenticated: true }); + const session = await sessionRes.json(); + const mustChange = session.user?.mustChangePassword === true; + setState({ + loading: false, + authEnabled: true, + isAuthenticated: true, + mustChangePassword: mustChange, + }); } else { localStorage.removeItem("stirling-token"); - setState({ loading: false, authEnabled: true, isAuthenticated: false }); + setState({ + loading: false, + authEnabled: true, + isAuthenticated: false, + mustChangePassword: false, + }); } } catch { // Can't reach API — assume no auth needed (dev mode) - setState({ loading: false, authEnabled: false, isAuthenticated: true }); + setState({ + loading: false, + authEnabled: false, + isAuthenticated: true, + mustChangePassword: false, + }); } } diff --git a/apps/web/src/pages/change-password-page.tsx b/apps/web/src/pages/change-password-page.tsx new file mode 100644 index 00000000..6f912747 --- /dev/null +++ b/apps/web/src/pages/change-password-page.tsx @@ -0,0 +1,135 @@ +import { type FormEvent, useState } from "react"; + +export function ChangePasswordPage() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + setLoading(true); + try { + const token = localStorage.getItem("stirling-token"); + const res = await fetch("/api/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "Failed to change password"); + return; + } + + // Password changed, reload to re-check auth state + window.location.href = "/"; + } catch { + setError("Connection error"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+

+ Stirling Image +

+

Change your password

+

+ You need to set a new password before continuing. Your password must be at least 8 + characters with uppercase, lowercase, and a number. +

+
+
+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" + required + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="At least 8 characters" + className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" + required + minLength={8} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Repeat new password" + className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+
+
+
+
+

Almost there

+

+ Set a strong password to secure your account, then you are good to go. +

+
+
+
+ ); +} diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index 730e29d1..606e7467 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -26,8 +26,12 @@ export function LoginPage() { setToken(data.token); // Store username for settings display localStorage.setItem("stirling-username", data.user?.username || username); - // Full reload to force auth re-check (useAuth runs on mount) - window.location.href = "/"; + // Redirect to password change if required, otherwise go home + if (data.user?.mustChangePassword) { + window.location.href = "/change-password"; + } else { + window.location.href = "/"; + } } catch { setError("Connection error"); } finally {