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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "User" ADD COLUMN "mustResetPassword" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -42,13 +42,14 @@ model Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String? @unique
|
username String? @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
role String @default("USER")
|
mustResetPassword Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
role String @default("USER")
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
|
|||||||
+83
-6
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +101,11 @@ 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"
|
||||||
: showRegister
|
: isPasswordReset
|
||||||
? "Create a new account"
|
? "Reset the admin password"
|
||||||
: "Sign in to access your drawings"}
|
: showRegister
|
||||||
|
? "Create a new account"
|
||||||
|
: "Sign in to access your drawings"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
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">
|
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
|
||||||
Password
|
{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={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user