Skip to content

Commit ef1f42b

Browse files
committed
feat(ui): implement password recovery flow
Add ForgotPassword and UpdatePassword pages for the cloud-gated password recovery flow. Includes forgot password link on login (cloud-only), API functions for the recovery endpoints, route gating via getConfig().cloud, success notice on redirect back to login, and encodeURIComponent on the uid path param. Also fixes the change-password payload to send only password fields, as the backend's applyUserChanges skips empty strings.
1 parent 1392a53 commit ef1f42b

File tree

7 files changed

+401
-7
lines changed

7 files changed

+401
-7
lines changed

ui-react/apps/admin/src/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@heroicons/react/24/outline";
88
import Login from "./pages/Login";
99
import Setup from "./pages/Setup";
10+
import { getConfig } from "./env";
1011
import AppLayout from "./components/layout/AppLayout";
1112
import LoginLayout from "./components/layout/LoginLayout";
1213
import ConnectivityGuard from "./components/common/ConnectivityGuard";
@@ -29,6 +30,8 @@ const FirewallRulesPage = lazy(() => import("./pages/firewall-rules"));
2930
const Settings = lazy(() => import("./pages/Settings"));
3031
const BannerEdit = lazy(() => import("./pages/BannerEdit"));
3132
const Profile = lazy(() => import("./pages/Profile"));
33+
const ForgotPassword = lazy(() => import("./pages/ForgotPassword"));
34+
const UpdatePassword = lazy(() => import("./pages/UpdatePassword"));
3235

3336
export default function App() {
3437
return (
@@ -39,6 +42,12 @@ export default function App() {
3942
<Route element={<LoginLayout />}>
4043
<Route path="/login" element={<Login />} />
4144
<Route path="/setup" element={<Setup />} />
45+
{getConfig().cloud && (
46+
<>
47+
<Route path="/forgot-password" element={<ForgotPassword />} />
48+
<Route path="/update-password" element={<UpdatePassword />} />
49+
</>
50+
)}
4251
</Route>
4352
<Route element={<ProtectedRoute />}>
4453
<Route element={<NamespaceGuard />}>

ui-react/apps/admin/src/api/auth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,15 @@ export async function updatePassword(
5151
password: newPassword,
5252
});
5353
}
54+
55+
export async function recoverPassword(username: string): Promise<void> {
56+
await apiClient.post("/api/user/recover_password", { username });
57+
}
58+
59+
export async function updateRecoverPassword(
60+
uid: string,
61+
token: string,
62+
password: string,
63+
): Promise<void> {
64+
await apiClient.post(`/api/user/${encodeURIComponent(uid)}/update_password`, { token, password });
65+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useState, FormEvent } from "react";
2+
import { Link } from "react-router-dom";
3+
import {
4+
EnvelopeIcon,
5+
CheckCircleIcon,
6+
LockClosedIcon,
7+
} from "@heroicons/react/24/outline";
8+
import { recoverPassword } from "../api/auth";
9+
10+
export default function ForgotPassword() {
11+
const [account, setAccount] = useState("");
12+
const [loading, setLoading] = useState(false);
13+
const [sent, setSent] = useState(false);
14+
15+
const handleSubmit = async (e: FormEvent) => {
16+
e.preventDefault();
17+
setLoading(true);
18+
try {
19+
await recoverPassword(account.trim());
20+
} catch {
21+
// Silently ignore to prevent user enumeration.
22+
} finally {
23+
setLoading(false);
24+
setSent(true);
25+
}
26+
};
27+
28+
return (
29+
<div className="w-full max-w-5xl mx-auto px-8 py-12 flex flex-col items-center">
30+
{/* Hero */}
31+
<div className="text-center mb-12 animate-fade-in">
32+
<div className="animate-float mb-6 inline-block">
33+
<div className="w-20 h-20 rounded-2xl bg-primary/15 border border-primary/25 flex items-center justify-center shadow-lg shadow-primary/10">
34+
<LockClosedIcon className="w-10 h-10 text-primary" strokeWidth={1.2} />
35+
</div>
36+
</div>
37+
38+
<p className="text-2xs font-mono font-semibold uppercase tracking-wide text-primary/80 mb-2">
39+
Password Recovery
40+
</p>
41+
<h1 className="text-3xl font-bold text-text-primary mb-3">
42+
Forgot your password?
43+
</h1>
44+
<p className="text-sm text-text-muted max-w-md mx-auto leading-relaxed">
45+
Enter your username or email address and we&apos;ll send you a link to
46+
reset your password.
47+
</p>
48+
</div>
49+
50+
{/* Card */}
51+
<div
52+
className="w-full max-w-sm bg-card/80 border border-border rounded-2xl p-8 backdrop-blur-sm animate-slide-up"
53+
style={{ animationDelay: "200ms" }}
54+
>
55+
{sent ? (
56+
<div role="alert" className="flex flex-col items-center text-center gap-4">
57+
<div className="w-12 h-12 rounded-full bg-accent-green/15 border border-accent-green/25 flex items-center justify-center">
58+
<CheckCircleIcon className="w-6 h-6 text-accent-green" strokeWidth={1.5} />
59+
</div>
60+
<div>
61+
<p className="text-sm font-semibold text-text-primary mb-1">Check your inbox</p>
62+
<p className="text-xs text-text-muted leading-relaxed">
63+
An email with password reset instructions has been sent to your
64+
registered email address.
65+
</p>
66+
</div>
67+
</div>
68+
) : (
69+
<form onSubmit={handleSubmit} className="space-y-5">
70+
<div>
71+
<label
72+
htmlFor="account"
73+
className="block text-2xs font-mono font-semibold uppercase tracking-label text-text-muted mb-2.5"
74+
>
75+
Username or email address
76+
</label>
77+
<input
78+
id="account"
79+
type="text"
80+
value={account}
81+
onChange={(e) => setAccount(e.target.value)}
82+
required
83+
autoFocus
84+
autoComplete="username"
85+
className="w-full px-4 py-3 bg-background border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-secondary focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all duration-200"
86+
placeholder="username or email"
87+
/>
88+
</div>
89+
90+
<button
91+
type="submit"
92+
disabled={loading || !account.trim()}
93+
className="w-full bg-primary hover:bg-primary-600 text-white py-3 px-4 rounded-lg text-sm font-semibold disabled:opacity-dim disabled:cursor-not-allowed transition-all duration-200 mt-1 flex items-center justify-center gap-2"
94+
>
95+
{loading ? (
96+
<>
97+
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
98+
<span className="font-mono text-xs">Sending...</span>
99+
</>
100+
) : (
101+
<>
102+
<EnvelopeIcon className="w-4 h-4" strokeWidth={2} />
103+
Reset Password
104+
</>
105+
)}
106+
</button>
107+
</form>
108+
)}
109+
</div>
110+
111+
{/* Back to login */}
112+
<div
113+
className="mt-8 animate-fade-in"
114+
style={{ animationDelay: "600ms" }}
115+
>
116+
<Link
117+
to="/login"
118+
className="text-xs text-text-muted hover:text-text-secondary transition-colors"
119+
>
120+
&larr; Back to login
121+
</Link>
122+
</div>
123+
</div>
124+
);
125+
}

ui-react/apps/admin/src/pages/Login.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { useState, FormEvent } from "react";
2-
import { useNavigate } from "react-router-dom";
2+
import { useNavigate, Link, useLocation } from "react-router-dom";
33
import {
44
ExclamationCircleIcon,
5+
CheckCircleIcon,
56
LockClosedIcon,
67
BookOpenIcon,
78
} from "@heroicons/react/24/outline";
89
import { useAuthStore } from "../stores/authStore";
10+
import { getConfig } from "../env";
911

1012
export default function Login() {
13+
const isCloud = getConfig().cloud;
14+
const location = useLocation();
15+
const rawState = location.state as Record<string, unknown> | null;
16+
const notice = typeof rawState?.notice === "string" ? rawState.notice : undefined;
1117
const [username, setUsername] = useState("");
1218
const [password, setPassword] = useState("");
1319
const { login, loading, error } = useAuthStore();
@@ -52,8 +58,14 @@ export default function Login() {
5258
style={{ animationDelay: "200ms" }}
5359
>
5460
<form onSubmit={handleSubmit} className="space-y-5">
61+
{notice && (
62+
<div role="alert" className="flex items-center gap-2 bg-accent-green/8 border border-accent-green/20 text-accent-green px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down">
63+
<CheckCircleIcon className="w-3.5 h-3.5 shrink-0" strokeWidth={2} />
64+
{notice}
65+
</div>
66+
)}
5567
{error && (
56-
<div className="flex items-center gap-2 bg-accent-red/8 border border-accent-red/20 text-accent-red px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down">
68+
<div role="alert" className="flex items-center gap-2 bg-accent-red/8 border border-accent-red/20 text-accent-red px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down">
5769
<ExclamationCircleIcon
5870
className="w-3.5 h-3.5 shrink-0"
5971
strokeWidth={2}
@@ -99,6 +111,17 @@ export default function Login() {
99111
/>
100112
</div>
101113

114+
{isCloud && (
115+
<div className="flex justify-end">
116+
<Link
117+
to="/forgot-password"
118+
className="text-2xs text-text-muted hover:text-text-secondary transition-colors"
119+
>
120+
Forgot password?
121+
</Link>
122+
</div>
123+
)}
124+
102125
<button
103126
type="submit"
104127
disabled={loading}

ui-react/apps/admin/src/pages/Profile.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PageHeader from "../components/common/PageHeader";
44
import Drawer from "../components/common/Drawer";
55
import { AxiosError } from "axios";
66
import { LABEL, INPUT } from "../utils/styles";
7+
import { validatePassword } from "../utils/validation";
78
import {
89
UserIcon,
910
PencilSquareIcon,
@@ -48,11 +49,6 @@ function validateRecoveryEmail(v: string, primary: string): string | null {
4849
return null;
4950
}
5051

51-
function validatePassword(v: string): string | null {
52-
if (v.length < 5) return "Password must be at least 5 characters";
53-
if (v.length > 32) return "Password must be at most 32 characters";
54-
return null;
55-
}
5652

5753
/* ─── Settings Card ─── */
5854

0 commit comments

Comments
 (0)