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 502ced98..7168fb95 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ 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;