mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
+10
-3
@@ -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 <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Force password change before allowing access to the app
|
||||
if (authEnabled && mustChangePassword) {
|
||||
return <Navigate to="/change-password" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -88,6 +94,7 @@ export function App() {
|
||||
<AuthGuard>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Stirling <span className="text-primary">Image</span>
|
||||
</h1>
|
||||
<h2 className="text-2xl font-bold mt-4 text-foreground">Change your password</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
You need to set a new password before continuing. Your password must be at least 8
|
||||
characters with uppercase, lowercase, and a number.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="current-password"
|
||||
className="block text-sm font-medium mb-1 text-foreground"
|
||||
>
|
||||
Current password
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="new-password"
|
||||
className="block text-sm font-medium mb-1 text-foreground"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="block text-sm font-medium mb-1 text-foreground"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
|
||||
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Changing..." : "Change password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
|
||||
<div className="max-w-lg space-y-6 text-center">
|
||||
<h2 className="text-3xl font-bold">Almost there</h2>
|
||||
<p className="text-lg text-white/80">
|
||||
Set a strong password to secure your account, then you are good to go.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user