feat(auth): add password reset functionality and user model update

- Introduced a `mustResetPassword` field in the User model to manage password reset requirements.
- Enhanced authentication flow to support password changes, including validation and error handling.
- Updated frontend components to handle password reset scenarios and integrate with the new API endpoints.
- Modified authentication context and hooks to accommodate the new password reset logic.
- Adjusted E2E tests to ensure proper coverage for the password reset functionality.
This commit is contained in:
Adrian Acala
2026-01-18 12:33:25 -08:00
parent 1a52fe80f3
commit 15ac634d15
12 changed files with 370 additions and 32 deletions
+4 -3
View File
@@ -179,9 +179,10 @@ AUTH_COOKIE_NAME=excalidash_auth
AUTH_COOKIE_SAMESITE=lax AUTH_COOKIE_SAMESITE=lax
``` ```
Once logged in, admins can toggle user registration and grant other admins from Once logged in, admins can manage user registration settings and user roles from
Settings. If no admin credentials are provided, the UI will prompt to create the the Settings page. When no admin credentials are provided via environment variables,
first admin account. an initial admin user is created with a randomly generated password that is logged
to the console on startup.
# Development # Development
View File
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "mustResetPassword" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -46,6 +46,7 @@ model User {
username String? @unique username String? @unique
email String? @unique email String? @unique
passwordHash String passwordHash String
mustResetPassword Boolean @default(false)
role String @default("USER") role String @default("USER")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+83 -6
View File
@@ -160,6 +160,7 @@ type AuthenticatedUser = {
username: string | null; username: string | null;
email: string | null; email: string | null;
role: string; role: string;
mustResetPassword?: boolean;
}; };
const toAuthUser = (user: AuthenticatedUser) => ({ const toAuthUser = (user: AuthenticatedUser) => ({
@@ -169,6 +170,11 @@ const toAuthUser = (user: AuthenticatedUser) => ({
role: user.role, role: user.role,
}); });
const toAuthUserWithResetFlag = (user: AuthenticatedUser & { mustResetPassword: boolean }) => ({
...toAuthUser(user),
mustResetPassword: user.mustResetPassword,
});
const ensureSystemConfig = async () => { const ensureSystemConfig = async () => {
await prisma.systemConfig.upsert({ await prisma.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID }, where: { id: DEFAULT_SYSTEM_CONFIG_ID },
@@ -220,6 +226,7 @@ const ensureInitialAdminUser = async () => {
username: resolved.username, username: resolved.username,
email: resolved.email, email: resolved.email,
passwordHash, passwordHash,
mustResetPassword: resolved.generatedPassword,
role: "ADMIN", role: "ADMIN",
}, },
}); });
@@ -489,12 +496,14 @@ const authExemptPaths = new Set([
"/auth/bootstrap", "/auth/bootstrap",
"/auth/registration/toggle", "/auth/registration/toggle",
"/auth/admins", "/auth/admins",
"/auth/password",
]); ]);
const authNeedsSession = new Set([ const authNeedsSession = new Set([
"/auth/logout", "/auth/logout",
"/auth/registration/toggle", "/auth/registration/toggle",
"/auth/admins", "/auth/admins",
"/auth/password",
]); ]);
const fetchSessionUser = async ( const fetchSessionUser = async (
@@ -503,7 +512,7 @@ const fetchSessionUser = async (
if (!session) return null; if (!session) return null;
return prisma.user.findUnique({ return prisma.user.findUnique({
where: { id: session.userId }, where: { id: session.userId },
select: { id: true, username: true, email: true, role: true }, select: { id: true, username: true, email: true, role: true, mustResetPassword: true },
}); });
}; };
@@ -639,6 +648,15 @@ const authLoginSchema = z.object({
password: z.string().min(1).max(512), password: z.string().min(1).max(512),
}); });
const authChangePasswordSchema = z.object({
currentPassword: z.string().min(1).max(512),
newPassword: z.string().min(1).max(512),
});
const authChangePasswordResponse = (user: AuthenticatedUser & { mustResetPassword: boolean }) => ({
user: toAuthUserWithResetFlag(user),
});
const authRegisterSchema = z.object({ const authRegisterSchema = z.object({
username: z.string().trim().min(1).max(200).optional(), username: z.string().trim().min(1).max(200).optional(),
email: z.string().trim().email().max(200).optional(), email: z.string().trim().email().max(200).optional(),
@@ -669,7 +687,7 @@ app.get("/auth/status", async (req, res) => {
authenticated: Boolean(user), authenticated: Boolean(user),
registrationEnabled: Boolean(config?.registrationEnabled), registrationEnabled: Boolean(config?.registrationEnabled),
bootstrapRequired: totalUsers === 0, bootstrapRequired: totalUsers === 0,
user: user ? toAuthUser(user) : null, user: user ? toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }) : null,
}); });
}); });
@@ -693,6 +711,14 @@ app.post("/auth/login", async (req, res) => {
{ email: identifier }, { email: identifier },
], ],
}, },
select: {
id: true,
username: true,
email: true,
role: true,
passwordHash: true,
mustResetPassword: true,
},
}); });
if (!user || !verifyPassword(password, user.passwordHash)) { if (!user || !verifyPassword(password, user.passwordHash)) {
@@ -715,7 +741,7 @@ app.post("/auth/login", async (req, res) => {
res.setHeader("Cache-Control", "no-store"); res.setHeader("Cache-Control", "no-store");
return res.json({ return res.json({
authenticated: true, authenticated: true,
user: toAuthUser(user), user: toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }),
}); });
}); });
@@ -728,6 +754,56 @@ app.post("/auth/logout", (req, res) => {
return res.json({ authenticated: false }); return res.json({ authenticated: false });
}); });
app.post("/auth/password", async (req, res) => {
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
if (!session) {
return res.status(401).json({
error: "Unauthorized",
message: "Authentication required",
});
}
const parsed = authChangePasswordSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "Invalid payload",
message: "Current and new passwords are required.",
});
}
const { currentPassword, newPassword } = parsed.data;
if (!isPasswordValid(authConfig, newPassword)) {
return res.status(400).json({
error: "Weak password",
message: `Password must be at least ${authConfig.minPasswordLength} characters.`,
});
}
const currentUser = await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, username: true, email: true, role: true, passwordHash: true },
});
if (!currentUser || !verifyPassword(currentPassword, currentUser.passwordHash)) {
return res.status(401).json({
error: "Unauthorized",
message: "Current password is incorrect.",
});
}
const updated = await prisma.user.update({
where: { id: currentUser.id },
data: {
passwordHash: hashPassword(newPassword),
mustResetPassword: false,
},
select: { id: true, username: true, email: true, role: true, mustResetPassword: true },
});
res.setHeader("Cache-Control", "no-store");
return res.json(authChangePasswordResponse(updated));
});
app.post("/auth/register", async (req, res) => { app.post("/auth/register", async (req, res) => {
const config = await getSystemConfig(); const config = await getSystemConfig();
const existingUsers = await prisma.user.count(); const existingUsers = await prisma.user.count();
@@ -821,7 +897,7 @@ app.post("/auth/register", async (req, res) => {
}); });
return res.status(201).json({ return res.status(201).json({
user: toAuthUser(user), user: toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }),
}); });
}); });
@@ -915,7 +991,7 @@ app.post("/auth/bootstrap", async (req, res) => {
res.setHeader("Cache-Control", "no-store"); res.setHeader("Cache-Control", "no-store");
return res.status(201).json({ return res.status(201).json({
authenticated: true, authenticated: true,
user: toAuthUser(user), user: toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }),
}); });
} catch (error) { } catch (error) {
console.error("Bootstrap failed:", error); console.error("Bootstrap failed:", error);
@@ -1009,9 +1085,10 @@ app.post("/auth/admins", async (req, res) => {
const updated = await prisma.user.update({ const updated = await prisma.user.update({
where: { id: target.id }, where: { id: target.id },
data: { role: parsed.data.role }, data: { role: parsed.data.role },
select: { id: true, username: true, email: true, role: true, mustResetPassword: true },
}); });
return res.json({ user: toAuthUser(updated) }); return res.json({ user: toAuthUserWithResetFlag(updated) });
}); });
const filesFieldSchema = z const filesFieldSchema = z
+2 -2
View File
@@ -109,7 +109,7 @@ export default defineConfig({
{ {
command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts", command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
url: `${BACKEND_URL}/health`, url: `${BACKEND_URL}/health`,
reuseExistingServer: true, reuseExistingServer: false,
timeout: 120000, timeout: 120000,
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -131,7 +131,7 @@ export default defineConfig({
{ {
command: "cd ../frontend && npm run dev -- --host", command: "cd ../frontend && npm run dev -- --host",
url: FRONTEND_URL, url: FRONTEND_URL,
reuseExistingServer: true, reuseExistingServer: false,
timeout: 120000, timeout: 120000,
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
+37 -1
View File
@@ -7,6 +7,7 @@ type AuthStatus = {
enabled: boolean; enabled: boolean;
authenticated: boolean; authenticated: boolean;
bootstrapRequired?: boolean; bootstrapRequired?: boolean;
user?: { mustResetPassword?: boolean } | null;
}; };
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
@@ -43,10 +44,44 @@ const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus>
export const ensureApiAuthenticated = async (request: APIRequestContext) => { export const ensureApiAuthenticated = async (request: APIRequestContext) => {
const status = await fetchAuthStatus(request); const status = await fetchAuthStatus(request);
if (!status.enabled || status.authenticated) { if (!status.enabled || (status.authenticated && !status.user?.mustResetPassword)) {
return; return;
} }
const resetIfRequired = async () => {
if (!status.user?.mustResetPassword) return;
let resetResponse = await request.post(`${BASE_URL}/auth/password`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
currentPassword: AUTH_PASSWORD,
newPassword: AUTH_PASSWORD,
},
});
if (!resetResponse.ok() && resetResponse.status() === 403) {
await refreshCsrfToken(request);
resetResponse = await request.post(`${BASE_URL}/auth/password`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
currentPassword: AUTH_PASSWORD,
newPassword: AUTH_PASSWORD,
},
});
}
if (!resetResponse.ok()) {
const text = await resetResponse.text();
throw new Error(`Failed to reset admin password: ${resetResponse.status()} ${text}`);
}
};
if (status.bootstrapRequired) { if (status.bootstrapRequired) {
let response = await request.post(`${BASE_URL}/auth/bootstrap`, { let response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: { headers: {
@@ -78,6 +113,7 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
throw new Error(`Failed to bootstrap test session: ${response.status()} ${text}`); throw new Error(`Failed to bootstrap test session: ${response.status()} ${text}`);
} }
await resetIfRequired();
return; return;
} }
+127
View File
@@ -0,0 +1,127 @@
import type { Page } from "@playwright/test";
import { PrismaClient } from "../../backend/src/generated/client";
import { test, expect } from "./fixtures";
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
const DATABASE_URL = process.env.DATABASE_URL;
const ensureLoggedOut = async (page: Page) => {
await page.context().clearCookies();
await page.goto("/");
const signInPrompt = page.getByText("Sign in to access your drawings");
if (await signInPrompt.isVisible().catch(() => false)) {
return;
}
await page.goto("/settings");
const logoutButton = page.getByRole("button", { name: /Log out/i });
if (await logoutButton.isVisible().catch(() => false)) {
await logoutButton.click();
}
await expect(signInPrompt).toBeVisible();
};
const login = async (page: Page, password: string) => {
await page.getByLabel("Username or Email").fill(AUTH_USERNAME);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
};
const waitForResetOrDashboard = async (page: Page) => {
const resetPrompt = page.getByText("Reset the admin password");
const dashboardReady = page.getByPlaceholder("Search drawings...");
const settingsHeader = page.getByRole("heading", { name: "Settings" });
await Promise.race([
resetPrompt.waitFor({ state: "visible", timeout: 15000 }).catch(() => null),
dashboardReady.waitFor({ state: "visible", timeout: 15000 }).catch(() => null),
settingsHeader.waitFor({ state: "visible", timeout: 15000 }).catch(() => null),
]);
if (await resetPrompt.isVisible().catch(() => false)) {
return "reset" as const;
}
if (await dashboardReady.isVisible().catch(() => false)) {
return "dashboard" as const;
}
if (await settingsHeader.isVisible().catch(() => false)) {
return "settings" as const;
}
return "unknown" as const;
};
const ensureDashboard = async (page: Page) => {
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
};
const setMustResetPassword = async (enabled: boolean) => {
if (!DATABASE_URL) {
throw new Error("DATABASE_URL is not set for e2e test.");
}
const prisma = new PrismaClient({
datasources: {
db: { url: DATABASE_URL },
},
});
try {
const admin = await prisma.user.findFirst({
where: { username: AUTH_USERNAME },
select: { id: true },
});
if (!admin) {
throw new Error(`Admin user ${AUTH_USERNAME} not found.`);
}
await prisma.user.update({
where: { id: admin.id },
data: { mustResetPassword: enabled },
});
} finally {
await prisma.$disconnect();
}
};
test.describe("Admin password reset", () => {
test.use({ skipAuth: true });
test("prompts and clears reset requirement for generated admin password", async ({ page }) => {
await ensureLoggedOut(page);
await login(page, AUTH_PASSWORD);
let initialState = await waitForResetOrDashboard(page);
if (initialState === "settings") {
await page.goto("/");
initialState = await waitForResetOrDashboard(page);
}
if (initialState === "reset") {
await page.getByLabel("Current Password").fill(AUTH_PASSWORD);
await page.getByLabel("New Password").fill(AUTH_PASSWORD);
await page.getByLabel("Confirm Password").fill(AUTH_PASSWORD);
await page.getByRole("button", { name: "Reset password" }).click();
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
}
await setMustResetPassword(true);
await ensureLoggedOut(page);
await login(page, AUTH_PASSWORD);
await expect(page.getByText("Reset the admin password")).toBeVisible({ timeout: 30000 });
await page.getByLabel("Current Password").fill(AUTH_PASSWORD);
await page.getByLabel("New Password").fill(AUTH_PASSWORD);
await page.getByLabel("Confirm Password").fill(AUTH_PASSWORD);
await page.getByRole("button", { name: "Reset password" }).click();
await page.goto("/");
await ensureDashboard(page);
await ensureLoggedOut(page);
await login(page, AUTH_PASSWORD);
await ensureDashboard(page);
});
});
+15 -1
View File
@@ -13,7 +13,13 @@ export type AuthStatus = {
authenticated: boolean; authenticated: boolean;
registrationEnabled: boolean; registrationEnabled: boolean;
bootstrapRequired: boolean; bootstrapRequired: boolean;
user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null; user: {
id: string;
username: string | null;
email: string | null;
role: "ADMIN" | "USER";
mustResetPassword?: boolean;
} | null;
}; };
let unauthorizedHandler: (() => void) | null = null; let unauthorizedHandler: (() => void) | null = null;
@@ -172,6 +178,14 @@ export const updateUserRole = async (identifier: string, role: "ADMIN" | "USER")
return response.data; return response.data;
}; };
export const changePassword = async (payload: {
currentPassword: string;
newPassword: string;
}) => {
const response = await api.post<{ user: AuthStatus["user"] }>("/auth/password", payload);
return response.data;
};
const coerceTimestamp = (value: string | number | Date): number => { const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value; if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime(); if (value instanceof Date) return value.getTime();
+1 -1
View File
@@ -24,7 +24,7 @@ export const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children })
); );
} }
if (state.enabled && !state.authenticated) { if (state.enabled && (!state.authenticated || state.user?.mustResetPassword)) {
return <Login />; return <Login />;
} }
+30 -1
View File
@@ -14,7 +14,13 @@ type AuthState = {
authenticated: boolean; authenticated: boolean;
registrationEnabled: boolean; registrationEnabled: boolean;
bootstrapRequired: boolean; bootstrapRequired: boolean;
user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null; user: {
id: string;
username: string | null;
email: string | null;
role: "ADMIN" | "USER";
mustResetPassword?: boolean;
} | null;
loading: boolean; loading: boolean;
statusError: string | null; statusError: string | null;
}; };
@@ -25,6 +31,7 @@ type AuthContextValue = {
logout: () => Promise<void>; logout: () => Promise<void>;
register: (payload: { username?: string; email?: string; password: string }) => Promise<void>; register: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
bootstrapAdmin: (payload: { username?: string; email?: string; password: string }) => Promise<void>; bootstrapAdmin: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
changePassword: (payload: { currentPassword: string; newPassword: string }) => Promise<void>;
setRegistrationEnabled: (enabled: boolean) => Promise<void>; setRegistrationEnabled: (enabled: boolean) => Promise<void>;
updateUserRole: (identifier: string, role: "ADMIN" | "USER") => Promise<void>; updateUserRole: (identifier: string, role: "ADMIN" | "USER") => Promise<void>;
refreshStatus: () => Promise<void>; refreshStatus: () => Promise<void>;
@@ -71,6 +78,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} }
}, []); }, []);
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
refreshStatus();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [refreshStatus]);
useEffect(() => { useEffect(() => {
refreshStatus(); refreshStatus();
}, [refreshStatus]); }, [refreshStatus]);
@@ -115,6 +133,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
[refreshStatus] [refreshStatus]
); );
const changePassword = useCallback(
async (payload: { currentPassword: string; newPassword: string }) => {
await api.changePassword(payload);
await refreshStatus();
},
[refreshStatus]
);
const setRegistrationEnabled = useCallback( const setRegistrationEnabled = useCallback(
async (enabled: boolean) => { async (enabled: boolean) => {
await api.setRegistrationEnabled(enabled); await api.setRegistrationEnabled(enabled);
@@ -138,6 +164,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logout, logout,
register, register,
bootstrapAdmin, bootstrapAdmin,
changePassword,
setRegistrationEnabled, setRegistrationEnabled,
updateUserRole, updateUserRole,
refreshStatus, refreshStatus,
@@ -148,12 +175,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logout, logout,
register, register,
bootstrapAdmin, bootstrapAdmin,
changePassword,
setRegistrationEnabled, setRegistrationEnabled,
updateUserRole, updateUserRole,
refreshStatus, refreshStatus,
] ]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}; };
+59 -7
View File
@@ -1,19 +1,32 @@
import React, { useState } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Logo } from "../components/Logo"; import { Logo } from "../components/Logo";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
export const Login: React.FC = () => { export const Login: React.FC = () => {
const { state, login, register, bootstrapAdmin } = useAuth(); const { state, login, register, bootstrapAdmin, changePassword } = useAuth();
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [showRegister, setShowRegister] = useState(false); const [showRegister, setShowRegister] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mustResetPassword = Boolean(state.user?.mustResetPassword);
const isBootstrap = state.bootstrapRequired; const isBootstrap = state.bootstrapRequired;
const canRegister = state.registrationEnabled; const canRegister = state.registrationEnabled;
const isPasswordReset = !isBootstrap && mustResetPassword;
useEffect(() => {
if (!isPasswordReset) return;
setIdentifier(state.user?.username || state.user?.email || "");
setCurrentPassword("");
setPassword("");
setConfirmPassword("");
setShowRegister(false);
setError(null);
}, [isPasswordReset, state.user]);
const parseIdentifier = () => { const parseIdentifier = () => {
const trimmed = identifier.trim(); const trimmed = identifier.trim();
@@ -24,12 +37,32 @@ export const Login: React.FC = () => {
return { username: trimmed, email: "" }; return { username: trimmed, email: "" };
}; };
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (isPasswordReset) {
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
if (!currentPassword.trim()) {
setError("Enter your current password.");
return;
}
if (password.length === 0) {
setError("Enter a new password.");
return;
}
await changePassword({ currentPassword: currentPassword.trim(), newPassword: confirmPassword });
setCurrentPassword("");
setPassword("");
setConfirmPassword("");
return;
}
if (showRegister || isBootstrap) { if (showRegister || isBootstrap) {
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Passwords do not match."); setError("Passwords do not match.");
@@ -68,6 +101,8 @@ export const Login: React.FC = () => {
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium"> <p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
{isBootstrap {isBootstrap
? "Create the initial admin account" ? "Create the initial admin account"
: isPasswordReset
? "Reset the admin password"
: showRegister : showRegister
? "Create a new account" ? "Create a new account"
: "Sign in to access your drawings"} : "Sign in to access your drawings"}
@@ -88,12 +123,27 @@ export const Login: React.FC = () => {
/> />
</label> </label>
{isPasswordReset && (
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200"> <label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Password Current Password
<input
type="password"
name="current-password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
)}
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
{isPasswordReset ? "New Password" : "Password"}
<input <input
type="password" type="password"
name="password" name="password"
autoComplete={showRegister || isBootstrap ? "new-password" : "current-password"} autoComplete={showRegister || isBootstrap || isPasswordReset ? "new-password" : "current-password"}
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
@@ -101,7 +151,7 @@ export const Login: React.FC = () => {
/> />
</label> </label>
{(showRegister || isBootstrap) && ( {(showRegister || isBootstrap || isPasswordReset) && (
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200"> <label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Confirm Password Confirm Password
<input <input
@@ -131,6 +181,8 @@ export const Login: React.FC = () => {
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : isBootstrap ? ( ) : isBootstrap ? (
"Create Admin" "Create Admin"
) : isPasswordReset ? (
"Reset password"
) : showRegister ? ( ) : showRegister ? (
"Create account" "Create account"
) : ( ) : (
@@ -138,7 +190,7 @@ export const Login: React.FC = () => {
)} )}
</button> </button>
{!isBootstrap && ( {!isBootstrap && !isPasswordReset && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {