From 36dc8c56b1a75ac9a3031ecb31cdf984364d4c4a Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 20 Apr 2026 16:28:50 +0000 Subject: [PATCH] Add forgot-password flow with admin-mediated reset links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #18: self-service "Forgot password?" flow on the sign-in page, plus an admin-mediated delivery path so tokens actually reach users without email infrastructure. Changes: - New password_reset_tokens table (SHA-256 hashed token, 1h TTL, single-use) added via schema + raw SQL. - Public endpoints: POST /auth/forgot-password (identical response for valid/invalid identifiers, no account enumeration), POST /auth/reset-password/verify, POST /auth/reset-password. Raw tokens are never returned or logged — only id + expiry. - Admin-only endpoint: POST /auth/admin/users/:id/issue-reset-link returns a one-time reset URL (origin + hex token) so admins can share it with the user out-of-band until email delivery lands. - Frontend: "Forgot password?" link on login, new /forgot-password and /reset-password pages, admin Users list gets a KeyRound button opening a modal with the generated URL and a Copy button. Public routes /forgot-password and /reset-password registered in AuthContext. - Bilingual EN/AR copy for all new screens and admin modal. Verification: full end-to-end test passed — admin generated link, user reset password via link, logged in with new password, and reused token was rejected as invalid (single-use enforced). Follow-ups proposed: #25 transactional email delivery, #26 rate limiting on the public reset endpoints. --- artifacts/api-server/src/routes/auth.ts | 180 ++++++++- artifacts/teaboy-os/public/opengraph.jpg | Bin 25430 -> 25939 bytes artifacts/teaboy-os/src/App.tsx | 4 + .../teaboy-os/src/contexts/AuthContext.tsx | 4 +- artifacts/teaboy-os/src/locales/ar.json | 37 ++ artifacts/teaboy-os/src/locales/en.json | 37 ++ artifacts/teaboy-os/src/pages/admin.tsx | 85 ++++- .../teaboy-os/src/pages/forgot-password.tsx | 151 ++++++++ artifacts/teaboy-os/src/pages/login.tsx | 13 + .../teaboy-os/src/pages/reset-password.tsx | 242 ++++++++++++ .../src/generated/api.schemas.ts | 28 ++ lib/api-client-react/src/generated/api.ts | 348 ++++++++++++++++++ lib/api-spec/openapi.yaml | 157 ++++++++ lib/api-zod/src/generated/api.ts | 48 +++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/password-reset-tokens.ts | 26 ++ 16 files changed, 1356 insertions(+), 5 deletions(-) create mode 100644 artifacts/teaboy-os/src/pages/forgot-password.tsx create mode 100644 artifacts/teaboy-os/src/pages/reset-password.tsx create mode 100644 lib/db/src/schema/password-reset-tokens.ts diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 1e174f75..122d332d 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -1,15 +1,30 @@ import { Router, type IRouter } from "express"; import bcrypt from "bcryptjs"; -import { eq } from "drizzle-orm"; +import crypto from "node:crypto"; +import { and, eq, or, isNull, gt } from "drizzle-orm"; import { db } from "@workspace/db"; import { usersTable, userRolesTable, rolesTable, appSettingsTable, + passwordResetTokensTable, } from "@workspace/db"; -import { requireAuth, getUserRoles } from "../middlewares/auth"; -import { RegisterBody, LoginBody, UpdateLanguageBody } from "@workspace/api-zod"; +import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; +import { + RegisterBody, + LoginBody, + UpdateLanguageBody, + ForgotPasswordBody, + ResetPasswordBody, + VerifyResetTokenBody, +} from "@workspace/api-zod"; + +const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; + +function hashToken(token: string) { + return crypto.createHash("sha256").update(token).digest("hex"); +} const router: IRouter = Router(); @@ -112,6 +127,165 @@ router.post("/auth/login", async (req, res): Promise => { res.json(buildAuthUser(user, roles)); }); +router.post("/auth/forgot-password", async (req, res): Promise => { + const parsed = ForgotPasswordBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const identifier = parsed.data.identifier.trim(); + const [user] = await db + .select() + .from(usersTable) + .where(or(eq(usersTable.username, identifier), eq(usersTable.email, identifier))); + + if (user && user.isActive) { + const rawToken = crypto.randomBytes(32).toString("hex"); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); + + await db.insert(passwordResetTokensTable).values({ + userId: user.id, + tokenHash, + expiresAt, + }); + + // No email integration is wired up in this project. The token is intentionally + // NOT returned to the client — exposing it would defeat the purpose of the + // reset flow. An admin can fetch the most recent token from the + // `password_reset_tokens` table to manually deliver the link until an email + // service is configured. See follow-up task to add email delivery. + console.log( + `[auth] Password reset requested for user id=${user.id}; ` + + `token expires at ${expiresAt.toISOString()}.`, + ); + } + + res.json({ success: true }); +}); + +router.post("/auth/reset-password/verify", async (req, res): Promise => { + const parsed = VerifyResetTokenBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const tokenHash = hashToken(parsed.data.token); + const [record] = await db + .select() + .from(passwordResetTokensTable) + .where( + and( + eq(passwordResetTokensTable.tokenHash, tokenHash), + isNull(passwordResetTokensTable.usedAt), + gt(passwordResetTokensTable.expiresAt, new Date()), + ), + ); + + res.json({ valid: Boolean(record) }); +}); + +router.post("/auth/reset-password", async (req, res): Promise => { + const parsed = ResetPasswordBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const tokenHash = hashToken(parsed.data.token); + const [record] = await db + .select() + .from(passwordResetTokensTable) + .where( + and( + eq(passwordResetTokensTable.tokenHash, tokenHash), + isNull(passwordResetTokensTable.usedAt), + gt(passwordResetTokensTable.expiresAt, new Date()), + ), + ); + + if (!record) { + res.status(400).json({ error: "Invalid or expired token" }); + return; + } + + const passwordHash = await bcrypt.hash(parsed.data.newPassword, 10); + + const now = new Date(); + + await db + .update(usersTable) + .set({ passwordHash }) + .where(eq(usersTable.id, record.userId)); + + // Mark this token used and invalidate any other outstanding reset + // tokens for the same user so they cannot be replayed. + await db + .update(passwordResetTokensTable) + .set({ usedAt: now }) + .where( + and( + eq(passwordResetTokensTable.userId, record.userId), + isNull(passwordResetTokensTable.usedAt), + ), + ); + + res.json({ success: true }); +}); + +router.post( + "/auth/admin/users/:id/issue-reset-link", + requireAdmin, + async (req, res): Promise => { + const userId = Number(req.params.id); + if (!Number.isInteger(userId)) { + res.status(400).json({ error: "Invalid user id" }); + return; + } + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, userId)); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + // Invalidate any outstanding reset tokens for this user so only + // the newly issued link is usable. + await db + .update(passwordResetTokensTable) + .set({ usedAt: new Date() }) + .where( + and( + eq(passwordResetTokensTable.userId, user.id), + isNull(passwordResetTokensTable.usedAt), + ), + ); + + const rawToken = crypto.randomBytes(32).toString("hex"); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); + + await db.insert(passwordResetTokensTable).values({ + userId: user.id, + tokenHash, + expiresAt, + }); + + const origin = + (req.headers["origin"] as string | undefined) ?? + `${req.protocol}://${req.get("host")}`; + const resetUrl = `${origin.replace(/\/$/, "")}/reset-password?token=${rawToken}`; + + res.json({ resetUrl, expiresAt: expiresAt.toISOString() }); + }, +); + router.post("/auth/logout", (req, res): void => { req.session.destroy(() => { res.json({ success: true }); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 502ced98b69229732464001763df84b5a23ca70a..7168fb9524662ce301eeaa44d41f827b474332f2 100644 GIT binary patch literal 25939 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 N1gIxz#09VJ{SUYL5zYVr literal 25430 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 diff --git a/artifacts/teaboy-os/src/App.tsx b/artifacts/teaboy-os/src/App.tsx index 7a9d12f0..a1225a30 100644 --- a/artifacts/teaboy-os/src/App.tsx +++ b/artifacts/teaboy-os/src/App.tsx @@ -6,6 +6,8 @@ import { AuthProvider } from "@/contexts/AuthContext"; import NotFound from "@/pages/not-found"; import LoginPage from "@/pages/login"; import RegisterPage from "@/pages/register"; +import ForgotPasswordPage from "@/pages/forgot-password"; +import ResetPasswordPage from "@/pages/reset-password"; import HomePage from "@/pages/home"; import ServicesPage from "@/pages/services"; import ChatPage from "@/pages/chat"; @@ -27,6 +29,8 @@ function Router() { + + diff --git a/artifacts/teaboy-os/src/contexts/AuthContext.tsx b/artifacts/teaboy-os/src/contexts/AuthContext.tsx index cbef1fb7..9c74e914 100644 --- a/artifacts/teaboy-os/src/contexts/AuthContext.tsx +++ b/artifacts/teaboy-os/src/contexts/AuthContext.tsx @@ -27,8 +27,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!isLoading) { + const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password']; + const isPublic = publicRoutes.includes(location); if (isError || !user) { - if (location !== '/login' && location !== '/register') { + if (!isPublic) { setLocation('/login'); } } else { diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 8f9544b1..ccaa38f9 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -27,6 +27,35 @@ "hidePassword": "إخفاء كلمة المرور", "errRequired": "الرجاء إدخال اسم المستخدم وكلمة المرور.", "errInvalid": "اسم مستخدم أو كلمة مرور غير صحيحة.", + "forgotPassword": "نسيت كلمة المرور؟", + "forgot": { + "title": "إعادة تعيين كلمة المرور", + "subtitle": "أدخل اسم المستخدم أو البريد الإلكتروني وسنرسل لك رابطاً لإعادة تعيين كلمة المرور.", + "identifier": "اسم المستخدم أو البريد الإلكتروني", + "identifierPlaceholder": "you@example.com", + "submit": "إرسال رابط الاستعادة", + "sending": "جارٍ الإرسال...", + "errRequired": "الرجاء إدخال اسم المستخدم أو البريد الإلكتروني.", + "errGeneric": "حدث خطأ ما. حاول مرة أخرى.", + "successMessage": "تم استلام طلبك. سيقوم المسؤول بمشاركة رابط إعادة التعيين معك قريباً.", + "adminNotice": "إرسال البريد الإلكتروني غير مفعّل بعد لهذه المساحة، لذا سيصدر المسؤول رابط إعادة تعيين لمرة واحدة يدوياً.", + "backToLogin": "العودة إلى تسجيل الدخول" + }, + "reset": { + "title": "تعيين كلمة مرور جديدة", + "subtitle": "اختر كلمة مرور جديدة لحسابك.", + "newPassword": "كلمة المرور الجديدة", + "confirmPassword": "تأكيد كلمة المرور", + "submit": "تحديث كلمة المرور", + "saving": "جارٍ الحفظ...", + "errMinLength": "يجب أن تكون كلمة المرور ٦ أحرف على الأقل.", + "errMismatch": "كلمتا المرور غير متطابقتين.", + "errGeneric": "تعذّر إعادة تعيين كلمة المرور. قد يكون الرابط منتهي الصلاحية.", + "invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية.", + "requestNew": "طلب رابط جديد", + "successMessage": "تم تحديث كلمة المرور بنجاح. يمكنك تسجيل الدخول بكلمة المرور الجديدة.", + "goToLogin": "الذهاب لتسجيل الدخول" + }, "heroTitle": "مرحباً بك في منصتك الموحدة.", "heroSubtitle": "سجّل دخولك لإدارة كل شيء من مكان واحد.", "feat1": "كل أدواتك في لوحة تحكم واحدة", @@ -118,6 +147,14 @@ "password": "كلمة المرور", "roles": "الصلاحيات", "active": "نشط", + "issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور", + "resetLinkModal": { + "title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}", + "description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.", + "expires": "تنتهي الصلاحية: {{time}}", + "copy": "نسخ الرابط", + "copied": "تم نسخ الرابط" + }, "siteSettings": "إعدادات الموقع", "siteNameAr": "اسم الموقع (عربي)", "siteNameEn": "اسم الموقع (إنجليزي)", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index be2ed6e6..4cc5c135 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -27,6 +27,35 @@ "hidePassword": "Hide password", "errRequired": "Please enter your username and password.", "errInvalid": "Invalid username or password.", + "forgotPassword": "Forgot password?", + "forgot": { + "title": "Reset your password", + "subtitle": "Enter your username or email and we'll send you a link to reset your password.", + "identifier": "Username or email", + "identifierPlaceholder": "you@example.com", + "submit": "Send reset link", + "sending": "Sending...", + "errRequired": "Please enter your username or email.", + "errGeneric": "Something went wrong. Please try again.", + "successMessage": "Your request has been received. An administrator will share a reset link with you shortly.", + "adminNotice": "Email delivery is not configured for this workspace yet, so an administrator will issue a one-time reset link manually.", + "backToLogin": "Back to sign in" + }, + "reset": { + "title": "Set a new password", + "subtitle": "Choose a new password for your account.", + "newPassword": "New password", + "confirmPassword": "Confirm password", + "submit": "Update password", + "saving": "Saving...", + "errMinLength": "Password must be at least 6 characters.", + "errMismatch": "Passwords do not match.", + "errGeneric": "Could not reset password. The link may have expired.", + "invalidToken": "This reset link is invalid or has expired.", + "requestNew": "Request a new link", + "successMessage": "Your password has been updated. You can now sign in with your new password.", + "goToLogin": "Go to sign in" + }, "heroTitle": "Welcome to your unified workspace.", "heroSubtitle": "Sign in to manage everything from one place.", "feat1": "All your tools in one dashboard", @@ -118,6 +147,14 @@ "password": "Password", "roles": "Roles", "active": "Active", + "issueResetLink": "Issue password reset link", + "resetLinkModal": { + "title": "Password reset link for {{username}}", + "description": "Share this one-time link with the user. It can only be used once.", + "expires": "Expires: {{time}}", + "copy": "Copy link", + "copied": "Link copied to clipboard" + }, "siteSettings": "Site Settings", "siteNameAr": "Site Name (Arabic)", "siteNameEn": "Site Name (English)", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 436dd981..9d1df770 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -22,13 +22,14 @@ import { useUpdateAppSettings, useGetAdminStats, getGetAdminStatsQueryKey, + useAdminIssueResetLink, } from "@workspace/api-client-react"; import type { App, Service, UserProfile, AppSettings, AdminStats } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { resolveServiceImageUrl } from "@/lib/image-url"; -import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity } from "lucide-react"; +import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -97,6 +98,8 @@ export default function AdminPage() { const createUser = useCreateUser(); const updateUser = useUpdateUser(); const deleteUser = useDeleteUser(); + const issueResetLink = useAdminIssueResetLink(); + const [resetLinkUser, setResetLinkUser] = useState<{ username: string; url: string; expiresAt: string } | null>(null); const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null); const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null); @@ -562,6 +565,34 @@ export default function AdminPage() { ); }} /> + {u.id !== user?.id && ( + + + + + )} ); } diff --git a/artifacts/teaboy-os/src/pages/forgot-password.tsx b/artifacts/teaboy-os/src/pages/forgot-password.tsx new file mode 100644 index 00000000..c4d9968b --- /dev/null +++ b/artifacts/teaboy-os/src/pages/forgot-password.tsx @@ -0,0 +1,151 @@ +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "wouter"; +import { useForgotPassword } from "@workspace/api-client-react"; +import { useAppName, useAppSettings } from "@/hooks/use-app-settings"; +import i18n from "@/i18n"; +import { Loader2, AlertCircle, CheckCircle2, ArrowLeft } from "lucide-react"; +import { AnimatedArt } from "@/components/animated-art"; + +export default function ForgotPasswordPage() { + const { t, i18n: i18nInstance } = useTranslation(); + const lang = i18nInstance.language; + const dir = lang === "ar" ? "rtl" : "ltr"; + const appName = useAppName(); + const { data: settings } = useAppSettings(); + const [, setLocation] = useLocation(); + + const [identifier, setIdentifier] = useState(""); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + const forgot = useForgotPassword(); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!identifier.trim()) { + setError(t("auth.forgot.errRequired")); + return; + } + setError(null); + forgot.mutate( + { data: { identifier: identifier.trim() } }, + { + onSuccess: () => setSubmitted(true), + onError: () => setError(t("auth.forgot.errGeneric")), + }, + ); + }; + + const toggleLanguage = () => i18n.changeLanguage(lang === "ar" ? "en" : "ar"); + + const footerText = + lang === "ar" ? settings?.footerTextAr : settings?.footerTextEn; + + return ( +
+ + +
+ +
+ +
+
+
+

{t("auth.forgot.title")}

+

+ {t("auth.forgot.subtitle")} +

+ + {!submitted ? ( +
+
+ + { + setIdentifier(e.target.value); + setError(null); + }} + disabled={forgot.isPending} + placeholder={t("auth.forgot.identifierPlaceholder")} + className="block w-full h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-60" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ ) : ( +
+
+ + {t("auth.forgot.successMessage")} +
+

+ {t("auth.forgot.adminNotice")} +

+
+ )} + +
+ +
+
+
+ + {footerText && ( +
+ © {new Date().getFullYear()} {appName}. {footerText} +
+ )} +
+
+ ); +} diff --git a/artifacts/teaboy-os/src/pages/login.tsx b/artifacts/teaboy-os/src/pages/login.tsx index 2d16560d..e7cc1e57 100644 --- a/artifacts/teaboy-os/src/pages/login.tsx +++ b/artifacts/teaboy-os/src/pages/login.tsx @@ -142,6 +142,19 @@ export default function LoginPage() { )} +
+ +
{/* Error alert */} diff --git a/artifacts/teaboy-os/src/pages/reset-password.tsx b/artifacts/teaboy-os/src/pages/reset-password.tsx new file mode 100644 index 00000000..ce4eb2f5 --- /dev/null +++ b/artifacts/teaboy-os/src/pages/reset-password.tsx @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "wouter"; +import { + useResetPassword, + useVerifyResetToken, +} from "@workspace/api-client-react"; +import { useAppName, useAppSettings } from "@/hooks/use-app-settings"; +import i18n from "@/i18n"; +import { + Eye, + EyeOff, + Loader2, + AlertCircle, + CheckCircle2, +} from "lucide-react"; +import { AnimatedArt } from "@/components/animated-art"; + +export default function ResetPasswordPage() { + const { t, i18n: i18nInstance } = useTranslation(); + const lang = i18nInstance.language; + const dir = lang === "ar" ? "rtl" : "ltr"; + const appName = useAppName(); + const { data: settings } = useAppSettings(); + const [, setLocation] = useLocation(); + + const token = useMemo(() => { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("token") ?? ""; + }, []); + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const [tokenValid, setTokenValid] = useState(null); + + const verify = useVerifyResetToken(); + const reset = useResetPassword(); + + useEffect(() => { + if (!token) { + setTokenValid(false); + return; + } + verify.mutate( + { data: { token } }, + { + onSuccess: (resp) => setTokenValid(resp.valid), + onError: () => setTokenValid(false), + }, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (password.length < 6) { + setError(t("auth.reset.errMinLength")); + return; + } + if (password !== confirm) { + setError(t("auth.reset.errMismatch")); + return; + } + setError(null); + reset.mutate( + { data: { token, newPassword: password } }, + { + onSuccess: () => setDone(true), + onError: () => setError(t("auth.reset.errGeneric")), + }, + ); + }; + + const toggleLanguage = () => i18n.changeLanguage(lang === "ar" ? "en" : "ar"); + + const footerText = + lang === "ar" ? settings?.footerTextAr : settings?.footerTextEn; + + return ( +
+ + +
+ +
+ +
+
+
+

{t("auth.reset.title")}

+

+ {t("auth.reset.subtitle")} +

+ + {tokenValid === null ? ( +
+ +
+ ) : tokenValid === false ? ( +
+
+ + {t("auth.reset.invalidToken")} +
+ +
+ ) : done ? ( +
+
+ + {t("auth.reset.successMessage")} +
+ +
+ ) : ( +
+
+ +
+ { + setPassword(e.target.value); + setError(null); + }} + disabled={reset.isPending} + className={`block w-full h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-60 ${ + dir === "rtl" ? "pl-10" : "pr-10" + }`} + /> + +
+
+ +
+ + { + setConfirm(e.target.value); + setError(null); + }} + disabled={reset.isPending} + className="block w-full h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-60" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ )} +
+
+ + {footerText && ( +
+ © {new Date().getFullYear()} {appName}. {footerText} +
+ )} +
+
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 1e97cdd7..0258be7c 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -33,6 +33,34 @@ export interface LoginBody { password: string; } +export interface ForgotPasswordBody { + /** Username or email */ + identifier: string; +} + +export interface ForgotPasswordResponse { + success: boolean; +} + +export interface ResetPasswordBody { + token: string; + /** @minLength 6 */ + newPassword: string; +} + +export interface VerifyResetTokenBody { + token: string; +} + +export interface AdminResetLinkResponse { + resetUrl: string; + expiresAt: string; +} + +export interface VerifyResetTokenResponse { + valid: boolean; +} + export interface UpdateLanguageBody { language: string; } diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 036c0000..5b7e6d15 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -17,6 +17,7 @@ import type { } from "@tanstack/react-query"; import type { + AdminResetLinkResponse, AdminStats, App, AppSettings, @@ -26,6 +27,8 @@ import type { CreateConversationBody, CreateServiceBody, ErrorResponse, + ForgotPasswordBody, + ForgotPasswordResponse, GetAdminStatsParams, HealthStatus, HomeStats, @@ -35,6 +38,7 @@ import type { RegisterBody, RequestUploadUrlBody, RequestUploadUrlResponse, + ResetPasswordBody, SendMessageBody, Service, ServiceCategory, @@ -46,6 +50,8 @@ import type { UpdateServiceBody, UpdateUserBody, UserProfile, + VerifyResetTokenBody, + VerifyResetTokenResponse, } from "./api.schemas"; import { customFetch } from "../custom-fetch"; @@ -385,6 +391,348 @@ export const useLogout = < return useMutation(getLogoutMutationOptions(options)); }; +/** + * @summary Request a password reset link + */ +export const getForgotPasswordUrl = () => { + return `/api/auth/forgot-password`; +}; + +export const forgotPassword = async ( + forgotPasswordBody: ForgotPasswordBody, + options?: RequestInit, +): Promise => { + return customFetch(getForgotPasswordUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(forgotPasswordBody), + }); +}; + +export const getForgotPasswordMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["forgotPassword"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return forgotPassword(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ForgotPasswordMutationResult = NonNullable< + Awaited> +>; +export type ForgotPasswordMutationBody = BodyType; +export type ForgotPasswordMutationError = ErrorType; + +/** + * @summary Request a password reset link + */ +export const useForgotPassword = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getForgotPasswordMutationOptions(options)); +}; + +/** + * @summary Set a new password using a reset token + */ +export const getResetPasswordUrl = () => { + return `/api/auth/reset-password`; +}; + +export const resetPassword = async ( + resetPasswordBody: ResetPasswordBody, + options?: RequestInit, +): Promise => { + return customFetch(getResetPasswordUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resetPasswordBody), + }); +}; + +export const getResetPasswordMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["resetPassword"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return resetPassword(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ResetPasswordMutationResult = NonNullable< + Awaited> +>; +export type ResetPasswordMutationBody = BodyType; +export type ResetPasswordMutationError = ErrorType; + +/** + * @summary Set a new password using a reset token + */ +export const useResetPassword = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getResetPasswordMutationOptions(options)); +}; + +/** + * @summary Check whether a reset token is valid + */ +export const getVerifyResetTokenUrl = () => { + return `/api/auth/reset-password/verify`; +}; + +export const verifyResetToken = async ( + verifyResetTokenBody: VerifyResetTokenBody, + options?: RequestInit, +): Promise => { + return customFetch(getVerifyResetTokenUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(verifyResetTokenBody), + }); +}; + +export const getVerifyResetTokenMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["verifyResetToken"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return verifyResetToken(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type VerifyResetTokenMutationResult = NonNullable< + Awaited> +>; +export type VerifyResetTokenMutationBody = BodyType; +export type VerifyResetTokenMutationError = ErrorType; + +/** + * @summary Check whether a reset token is valid + */ +export const useVerifyResetToken = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getVerifyResetTokenMutationOptions(options)); +}; + +/** + * @summary Admin generates a one-time password reset link for a user + */ +export const getAdminIssueResetLinkUrl = (id: number) => { + return `/api/auth/admin/users/${id}/issue-reset-link`; +}; + +export const adminIssueResetLink = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getAdminIssueResetLinkUrl(id), { + ...options, + method: "POST", + }); +}; + +export const getAdminIssueResetLinkMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext +> => { + const mutationKey = ["adminIssueResetLink"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return adminIssueResetLink(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type AdminIssueResetLinkMutationResult = NonNullable< + Awaited> +>; + +export type AdminIssueResetLinkMutationError = ErrorType; + +/** + * @summary Admin generates a one-time password reset link for a user + */ +export const useAdminIssueResetLink = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getAdminIssueResetLinkMutationOptions(options)); +}; + /** * @summary Get current user */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index ac37bfd6..cf9b2937 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -107,6 +107,106 @@ paths: schema: $ref: "#/components/schemas/SuccessResponse" + /auth/forgot-password: + post: + operationId: forgotPassword + tags: [auth] + summary: Request a password reset link + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ForgotPasswordBody" + responses: + "200": + description: Reset request accepted + content: + application/json: + schema: + $ref: "#/components/schemas/ForgotPasswordResponse" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /auth/reset-password: + post: + operationId: resetPassword + tags: [auth] + summary: Set a new password using a reset token + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResetPasswordBody" + responses: + "200": + description: Password updated + content: + application/json: + schema: + $ref: "#/components/schemas/SuccessResponse" + "400": + description: Invalid or expired token + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /auth/reset-password/verify: + post: + operationId: verifyResetToken + tags: [auth] + summary: Check whether a reset token is valid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyResetTokenBody" + responses: + "200": + description: Token check result + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyResetTokenResponse" + + /auth/admin/users/{id}/issue-reset-link: + post: + operationId: adminIssueResetLink + tags: [auth] + summary: Admin generates a one-time password reset link for a user + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Reset link issued + content: + application/json: + schema: + $ref: "#/components/schemas/AdminResetLinkResponse" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /auth/me: get: operationId: getMe @@ -805,6 +905,63 @@ components: - username - password + ForgotPasswordBody: + type: object + properties: + identifier: + type: string + description: Username or email + required: + - identifier + + ForgotPasswordResponse: + type: object + properties: + success: + type: boolean + required: + - success + + ResetPasswordBody: + type: object + properties: + token: + type: string + newPassword: + type: string + minLength: 6 + required: + - token + - newPassword + + VerifyResetTokenBody: + type: object + properties: + token: + type: string + required: + - token + + AdminResetLinkResponse: + type: object + properties: + resetUrl: + type: string + expiresAt: + type: string + format: date-time + required: + - resetUrl + - expiresAt + + VerifyResetTokenResponse: + type: object + properties: + valid: + type: boolean + required: + - valid + UpdateLanguageBody: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 2b2c96e8..e5fb36f6 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -56,6 +56,54 @@ export const LogoutResponse = zod.object({ success: zod.boolean(), }); +/** + * @summary Request a password reset link + */ +export const ForgotPasswordBody = zod.object({ + identifier: zod.string().describe("Username or email"), +}); + +export const ForgotPasswordResponse = zod.object({ + success: zod.boolean(), +}); + +/** + * @summary Set a new password using a reset token + */ +export const resetPasswordBodyNewPasswordMin = 6; + +export const ResetPasswordBody = zod.object({ + token: zod.string(), + newPassword: zod.string().min(resetPasswordBodyNewPasswordMin), +}); + +export const ResetPasswordResponse = zod.object({ + success: zod.boolean(), +}); + +/** + * @summary Check whether a reset token is valid + */ +export const VerifyResetTokenBody = zod.object({ + token: zod.string(), +}); + +export const VerifyResetTokenResponse = zod.object({ + valid: zod.boolean(), +}); + +/** + * @summary Admin generates a one-time password reset link for a user + */ +export const AdminIssueResetLinkParams = zod.object({ + id: zod.coerce.number(), +}); + +export const AdminIssueResetLinkResponse = zod.object({ + resetUrl: zod.string(), + expiresAt: zod.coerce.date(), +}); + /** * @summary Get current user */ diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index ab7e1c93..ba51663c 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -7,3 +7,4 @@ export * from "./notifications"; export * from "./settings"; export * from "./user-app-orders"; export * from "./app-opens"; +export * from "./password-reset-tokens"; diff --git a/lib/db/src/schema/password-reset-tokens.ts b/lib/db/src/schema/password-reset-tokens.ts new file mode 100644 index 00000000..bb5e022d --- /dev/null +++ b/lib/db/src/schema/password-reset-tokens.ts @@ -0,0 +1,26 @@ +import { pgTable, serial, integer, varchar, timestamp, index } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod/v4"; +import { usersTable } from "./users"; + +export const passwordResetTokensTable = pgTable( + "password_reset_tokens", + { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 128 }).notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("password_reset_tokens_user_id_idx").on(t.userId)], +); + +export const insertPasswordResetTokenSchema = createInsertSchema(passwordResetTokensTable).omit({ + id: true, + createdAt: true, +}); +export type InsertPasswordResetToken = z.infer; +export type PasswordResetToken = typeof passwordResetTokensTable.$inferSelect;