Add password reset feature for admin users, enforce password change on login, and enhance session management
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
deleteUserAction,
|
deleteUserAction,
|
||||||
} from "@/app/actions/users";
|
} from "@/app/actions/users";
|
||||||
import { Flash } from "@/components/flash";
|
import { Flash } from "@/components/flash";
|
||||||
|
import { ResetUserPassword } from "@/components/reset-user-password";
|
||||||
import { requireAdminUser } from "@/lib/auth";
|
import { requireAdminUser } from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { formatDate, queryMessage } from "@/lib/format";
|
import { formatDate, queryMessage } from "@/lib/format";
|
||||||
@@ -34,6 +35,9 @@ export default async function AdminUsersPage({
|
|||||||
)
|
)
|
||||||
.all() as UserRow[];
|
.all() as UserRow[];
|
||||||
const adminCount = users.filter((u) => u.role === "admin").length;
|
const adminCount = users.filter((u) => u.role === "admin").length;
|
||||||
|
const currentAdminKeyCount = db
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?")
|
||||||
|
.get(currentUser.id) as { count: number };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -93,12 +97,20 @@ export default async function AdminUsersPage({
|
|||||||
</td>
|
</td>
|
||||||
<td>{formatDate(row.created_at.slice(0, 10))}</td>
|
<td>{formatDate(row.created_at.slice(0, 10))}</td>
|
||||||
<td>
|
<td>
|
||||||
{!isSelf && !lastAdmin && (
|
{!isSelf && (
|
||||||
|
<div className="user-admin-actions">
|
||||||
|
<ResetUserPassword
|
||||||
|
userId={row.id}
|
||||||
|
requiresYubiKey={currentAdminKeyCount.count > 0}
|
||||||
|
/>
|
||||||
|
{!lastAdmin && (
|
||||||
<form action={deleteUserAction} className="inline-form">
|
<form action={deleteUserAction} className="inline-form">
|
||||||
<input type="hidden" name="id" value={row.id} />
|
<input type="hidden" name="id" value={row.id} />
|
||||||
<button className="link-button danger" type="submit">Löschen</button>
|
<button className="link-button danger" type="submit">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
+38
-2
@@ -1,11 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { compareSync, hashSync } from "bcryptjs";
|
import { compareSync, hashSync } from "bcryptjs";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect, unstable_rethrow } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
clearPendingAdminMfaCookie,
|
clearPendingAdminMfaCookie,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
getSessionUser,
|
||||||
setPendingAdminMfaCookie,
|
setPendingAdminMfaCookie,
|
||||||
requireSessionUser,
|
requireSessionUser,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
@@ -77,10 +78,45 @@ export async function changePasswordAction(formData: FormData) {
|
|||||||
redirect(messageUrl("/account", "error", "Das aktuelle Passwort ist falsch."));
|
redirect(messageUrl("/account", "error", "Das aktuelle Passwort ist falsch."));
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashSync(newPassword, 12), user.id);
|
db.prepare(
|
||||||
|
"UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?",
|
||||||
|
).run(hashSync(newPassword, 12), user.id);
|
||||||
redirect(messageUrl("/account", "success", "Passwort wurde geändert."));
|
redirect(messageUrl("/account", "success", "Passwort wurde geändert."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function completeRequiredPasswordChangeAction(formData: FormData) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.mustChangePassword) redirect("/");
|
||||||
|
initDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newPassword = passwordValue(formData, "new_password");
|
||||||
|
if (newPassword !== String(formData.get("confirm_password") ?? "")) {
|
||||||
|
throw new Error("Die neuen Passwörter stimmen nicht überein.");
|
||||||
|
}
|
||||||
|
const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(user.id) as
|
||||||
|
| { password_hash: string }
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw new Error("Benutzer wurde nicht gefunden.");
|
||||||
|
if (compareSync(newPassword, row.password_hash)) {
|
||||||
|
throw new Error("Das neue Passwort muss sich vom temporären Passwort unterscheiden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ? AND active = 1",
|
||||||
|
).run(hashSync(newPassword, 12), user.id);
|
||||||
|
redirect("/");
|
||||||
|
} catch (error) {
|
||||||
|
unstable_rethrow(error);
|
||||||
|
redirect(messageUrl(
|
||||||
|
"/change-password",
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error.message : "Das Passwort konnte nicht geändert werden.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeAdminSecurityKeyAction(formData: FormData) {
|
export async function removeAdminSecurityKeyAction(formData: FormData) {
|
||||||
const user = await requireAdminUser();
|
const user = await requireAdminUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import { hashSync } from "bcryptjs";
|
import { hashSync } from "bcryptjs";
|
||||||
import { redirect, unstable_rethrow } from "next/navigation";
|
import { redirect, unstable_rethrow } from "next/navigation";
|
||||||
import { requireAdminUser } from "@/lib/auth";
|
import {
|
||||||
|
consumePasswordResetMfaAuthorization,
|
||||||
|
requireAdminUser,
|
||||||
|
} from "@/lib/auth";
|
||||||
import { db, initDatabase, transaction } from "@/lib/db";
|
import { db, initDatabase, transaction } from "@/lib/db";
|
||||||
import { idValue, passwordValue, roleValue, usernameValue } from "@/lib/validation";
|
import { idValue, passwordValue, roleValue, usernameValue } from "@/lib/validation";
|
||||||
import type { UserRole } from "@/lib/types";
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
|
export type ResetUserPasswordState = {
|
||||||
|
error?: string;
|
||||||
|
password?: string;
|
||||||
|
userName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function go(kind: "error" | "success", message: string): never {
|
function go(kind: "error" | "success", message: string): never {
|
||||||
redirect(`/admin/users?${kind}=${encodeURIComponent(message)}`);
|
redirect(`/admin/users?${kind}=${encodeURIComponent(message)}`);
|
||||||
}
|
}
|
||||||
@@ -70,6 +79,56 @@ export async function changeUserRoleAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetUserPasswordAction(
|
||||||
|
_previousState: ResetUserPasswordState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ResetUserPasswordState> {
|
||||||
|
const currentUser = await requireAdminUser();
|
||||||
|
initDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = idValue(formData);
|
||||||
|
if (id === currentUser.id) {
|
||||||
|
throw new Error("Das eigene Passwort kann unter „Konto“ geändert werden.");
|
||||||
|
}
|
||||||
|
const keyCount = db
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?")
|
||||||
|
.get(currentUser.id) as { count: number };
|
||||||
|
if (
|
||||||
|
keyCount.count > 0
|
||||||
|
&& !await consumePasswordResetMfaAuthorization(currentUser.id, id)
|
||||||
|
) {
|
||||||
|
throw new Error("Der Passwort-Reset muss mit deinem YubiKey bestätigt werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = randomBytes(18).toString("base64url");
|
||||||
|
const passwordHash = hashSync(password, 12);
|
||||||
|
let userName = "";
|
||||||
|
transaction(() => {
|
||||||
|
const target = db
|
||||||
|
.prepare("SELECT name FROM users WHERE id = ? AND active = 1")
|
||||||
|
.get(id) as { name: string } | undefined;
|
||||||
|
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
||||||
|
|
||||||
|
userName = target.name;
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?",
|
||||||
|
)
|
||||||
|
.run(passwordHash, id);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE user_sessions
|
||||||
|
SET revoked_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND revoked_at IS NULL`,
|
||||||
|
).run(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { password, userName };
|
||||||
|
} catch (error) {
|
||||||
|
unstable_rethrow(error);
|
||||||
|
return { error: errorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUserAction(formData: FormData) {
|
export async function deleteUserAction(formData: FormData) {
|
||||||
const currentUser = await requireAdminUser();
|
const currentUser = await requireAdminUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
|
|||||||
@@ -71,11 +71,27 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const user = db
|
const user = db
|
||||||
.prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1 AND role = 'admin'")
|
.prepare(
|
||||||
.get(userId) as { id: number; name: string; email: string; role: "admin" } | undefined;
|
`SELECT id, name, email, role,
|
||||||
|
must_change_password AS mustChangePassword
|
||||||
|
FROM users
|
||||||
|
WHERE id = ? AND active = 1 AND role = 'admin'`,
|
||||||
|
)
|
||||||
|
.get(userId) as
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "admin";
|
||||||
|
mustChangePassword: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
if (!user) return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 });
|
||||||
|
|
||||||
const token = await createSessionToken(user, {
|
const token = await createSessionToken({
|
||||||
|
...user,
|
||||||
|
mustChangePassword: user.mustChangePassword === 1,
|
||||||
|
}, {
|
||||||
userAgent: request.headers.get("user-agent"),
|
userAgent: request.headers.get("user-agent"),
|
||||||
ipAddress:
|
ipAddress:
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0] ??
|
request.headers.get("x-forwarded-for")?.split(",")[0] ??
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type AttachmentRow = {
|
|||||||
|
|
||||||
export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments/[id]">) {
|
export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments/[id]">) {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
if (!user || user.mustChangePassword) {
|
||||||
return new Response("Nicht angemeldet.", { status: 401 });
|
return new Response("Nicht angemeldet.", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -675,6 +675,47 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); }
|
|||||||
.inline-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; }
|
.inline-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; }
|
||||||
.inline-form select { width: auto; }
|
.inline-form select { width: auto; }
|
||||||
|
|
||||||
|
.user-admin-actions,
|
||||||
|
.password-reset {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-admin-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-reset {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-reset-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--rust);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temporary-password {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 17rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temporary-password code {
|
||||||
|
user-select: all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temporary-password small {
|
||||||
|
color: var(--steel);
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export default async function LoginPage({
|
|||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
initDatabase();
|
initDatabase();
|
||||||
if (await getSessionUser()) redirect("/");
|
const user = await getSessionUser();
|
||||||
|
if (user) redirect(user.mustChangePassword ? "/change-password" : "/");
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
return (
|
return (
|
||||||
<main className="login-page">
|
<main className="login-page">
|
||||||
|
|||||||
+73
-6
@@ -19,8 +19,16 @@ type AdminMfaPayload = {
|
|||||||
exp: number;
|
exp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PasswordResetMfaPayload = {
|
||||||
|
uid: number;
|
||||||
|
target: number;
|
||||||
|
purpose: "password-reset";
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const SESSION_COOKIE = "moped_session";
|
export const SESSION_COOKIE = "moped_session";
|
||||||
export const ADMIN_MFA_COOKIE = "moped_admin_mfa";
|
export const ADMIN_MFA_COOKIE = "moped_admin_mfa";
|
||||||
|
export const PASSWORD_RESET_MFA_COOKIE = "moped_password_reset_mfa";
|
||||||
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
||||||
const ADMIN_MFA_TTL_SECONDS = 60 * 5;
|
const ADMIN_MFA_TTL_SECONDS = 60 * 5;
|
||||||
|
|
||||||
@@ -137,6 +145,38 @@ export async function clearPendingAdminMfaCookie() {
|
|||||||
(await cookies()).delete(ADMIN_MFA_COOKIE);
|
(await cookies()).delete(ADMIN_MFA_COOKIE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createPasswordResetMfaToken(adminId: number, targetUserId: number) {
|
||||||
|
return new SignJWT({
|
||||||
|
uid: adminId,
|
||||||
|
target: targetUserId,
|
||||||
|
purpose: "password-reset",
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${ADMIN_MFA_TTL_SECONDS}s`)
|
||||||
|
.sign(getSecretKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumePasswordResetMfaAuthorization(
|
||||||
|
adminId: number,
|
||||||
|
targetUserId: number,
|
||||||
|
) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(PASSWORD_RESET_MFA_COOKIE)?.value;
|
||||||
|
cookieStore.delete(PASSWORD_RESET_MFA_COOKIE);
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await jwtVerify(token, getSecretKey());
|
||||||
|
const payload = result.payload as unknown as PasswordResetMfaPayload;
|
||||||
|
return payload.uid === adminId
|
||||||
|
&& payload.target === targetUserId
|
||||||
|
&& payload.purpose === "password-reset";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
@@ -148,7 +188,8 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
initDatabase();
|
initDatabase();
|
||||||
const user = db
|
const user = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.id, u.name, u.email, u.role
|
`SELECT u.id, u.name, u.email, u.role,
|
||||||
|
u.must_change_password AS mustChangePassword
|
||||||
FROM user_sessions s
|
FROM user_sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.id = ? AND s.user_id = ?
|
WHERE s.id = ? AND s.user_id = ?
|
||||||
@@ -157,7 +198,13 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
AND u.active = 1`,
|
AND u.active = 1`,
|
||||||
)
|
)
|
||||||
.get(payload.sid, Number(payload.id)) as
|
.get(payload.sid, Number(payload.id)) as
|
||||||
| { id: number; name: string; email: string; role: UserRole }
|
| {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
mustChangePassword: number;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -166,7 +213,9 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
AND last_seen_at < datetime('now', '-30 seconds')`,
|
AND last_seen_at < datetime('now', '-30 seconds')`,
|
||||||
).run(payload.sid);
|
).run(payload.sid);
|
||||||
return user ?? null;
|
return user
|
||||||
|
? { ...user, mustChangePassword: user.mustChangePassword === 1 }
|
||||||
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -187,6 +236,7 @@ export async function getCurrentSessionId() {
|
|||||||
export async function requireSessionUser() {
|
export async function requireSessionUser() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
if (user.mustChangePassword) redirect("/change-password");
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,12 +250,29 @@ export function authenticateUser(username: string, password: string): SessionUse
|
|||||||
initDatabase();
|
initDatabase();
|
||||||
const users = db
|
const users = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC",
|
`SELECT id, name, email, role, password_hash,
|
||||||
|
must_change_password AS mustChangePassword
|
||||||
|
FROM users
|
||||||
|
WHERE name = ? COLLATE NOCASE AND active = 1
|
||||||
|
ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
.all(username.trim()) as (SessionUser & { password_hash: string })[];
|
.all(username.trim()) as {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
password_hash: string;
|
||||||
|
mustChangePassword: number;
|
||||||
|
}[];
|
||||||
if (users.length !== 1) return null;
|
if (users.length !== 1) return null;
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
|
|
||||||
if (!user || !compareSync(password, user.password_hash)) return null;
|
if (!user || !compareSync(password, user.password_hash)) return null;
|
||||||
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
mustChangePassword: Boolean(user.mustChangePassword),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function ensureSchema() {
|
|||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
||||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||||
|
must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) STRICT;
|
) STRICT;
|
||||||
@@ -226,6 +227,11 @@ function ensureSchema() {
|
|||||||
if (!userColumns.some((column) => column.name === "deleted_at")) {
|
if (!userColumns.some((column) => column.name === "deleted_at")) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN deleted_at TEXT");
|
db.exec("ALTER TABLE users ADD COLUMN deleted_at TEXT");
|
||||||
}
|
}
|
||||||
|
if (!userColumns.some((column) => column.name === "must_change_password")) {
|
||||||
|
db.exec(
|
||||||
|
"ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1))",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const motorcycleColumns = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[];
|
const motorcycleColumns = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[];
|
||||||
if (!motorcycleColumns.some((column) => column.name === "tire_pressure_front_bar")) {
|
if (!motorcycleColumns.some((column) => column.name === "tire_pressure_front_bar")) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type SessionUser = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
mustChangePassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MotorcycleSummary = {
|
export type MotorcycleSummary = {
|
||||||
|
|||||||
Reference in New Issue
Block a user