@@ -200,6 +206,14 @@ export default function LoginForm({ ssoEnabled }: { ssoEnabled: boolean }) {
+ {showRegisterLink && (
+
{t('version', { version: appVersion })}
diff --git a/app/_server/actions/auth/index.ts b/app/_server/actions/auth/index.ts
index d14e6f67..9ea9f069 100644
--- a/app/_server/actions/auth/index.ts
+++ b/app/_server/actions/auth/index.ts
@@ -2,7 +2,7 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
-import { createHash } from "crypto";
+import { createHash, randomBytes } from "crypto";
import path from "path";
import { lock, unlock } from "proper-lockfile";
import {
@@ -23,8 +23,14 @@ import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
import fs from "fs/promises";
import { CHECKLISTS_DIR, NOTES_DIR, USERS_FILE } from "@/app/_consts/files";
import { logAuthEvent } from "../log";
-import { getUsername } from "../users";
-import { isEnvEnabled } from "@/app/_utils/env-utils";
+import { getUsername, ensureUser } from "../users";
+import {
+ isSecureEnv,
+ getAuthMode,
+ getSessionCookieName,
+ getMfaPendingCookieName,
+} from "@/app/_utils/env-utils";
+import { ldapLogin } from "./ldap";
interface User {
username: string;
@@ -33,12 +39,93 @@ interface User {
isSuperAdmin?: boolean;
createdAt?: string;
lastLogin?: string;
+ failedLoginAttempts?: number;
+ nextAllowedLoginAttempt?: string;
+ mfaEnabled?: boolean;
}
const hashPassword = (password: string): string => {
return createHash("sha256").update(password).digest("hex");
};
+const _generateSessionId = (): string => randomBytes(32).toString("hex");
+
+async function _setSessionCookie(
+ sessionId: string,
+ cookieName: string,
+ maxAge: number,
+) {
+ (await cookies()).set(cookieName, sessionId, {
+ httpOnly: true,
+ secure: isSecureEnv(),
+ sameSite: "lax",
+ maxAge,
+ path: "/",
+ });
+}
+
+async function _handleFailedLogin(
+ users: User[],
+ username: string,
+ bruteforceProtectionDisabled: boolean,
+) {
+ const user = users.find(
+ (u: User) => u.username.toLowerCase() === username.toLowerCase(),
+ );
+
+ if (user && !bruteforceProtectionDisabled) {
+ const userIndex = users.findIndex(
+ (u: User) => u.username.toLowerCase() === username.toLowerCase(),
+ );
+
+ if (userIndex !== -1) {
+ const failedAttempts =
+ (users[userIndex].failedLoginAttempts || 0) + 1;
+ users[userIndex].failedLoginAttempts = failedAttempts;
+
+ const delayMs = _youShallNotPass(failedAttempts);
+ let lockedUntil: string | undefined;
+ if (delayMs > 0) {
+ lockedUntil = new Date(Date.now() + delayMs).toISOString();
+ users[userIndex].nextAllowedLoginAttempt = lockedUntil;
+ } else {
+ users[userIndex].nextAllowedLoginAttempt = undefined;
+ }
+
+ await writeJsonFile(users, USERS_FILE);
+
+ await logAuthEvent(
+ "login",
+ username,
+ false,
+ `Invalid credentials - attempt ${failedAttempts}`,
+ );
+
+ const attemptsRemaining = Math.max(0, 4 - failedAttempts);
+ const waitSeconds = delayMs > 0 ? Math.ceil(delayMs / 1000) : 0;
+
+ return {
+ error:
+ delayMs > 0
+ ? "Too many failed attempts"
+ : "Invalid username or password",
+ attemptsRemaining,
+ failedAttempts,
+ ...(lockedUntil && { lockedUntil, waitSeconds }),
+ };
+ }
+ }
+
+ await logAuthEvent("login", username, false, "Invalid username or password");
+ return {
+ error: "Invalid username or password",
+ attemptsRemaining: undefined as number | undefined,
+ failedAttempts: undefined as number | undefined,
+ lockedUntil: undefined as string | undefined,
+ waitSeconds: undefined as number | undefined,
+ };
+}
+
/**
* 🧙‍♂️
*/
@@ -87,9 +174,7 @@ export const register = async (formData: FormData) => {
users.push(newUser);
await writeJsonFile(users, USERS_FILE);
- const sessionId = createHash("sha256")
- .update(Math.random().toString())
- .digest("hex");
+ const sessionId = _generateSessionId();
let sessions = await readSessions();
@@ -103,19 +188,7 @@ export const register = async (formData: FormData) => {
await writeSessions(sessions);
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
-
- (await cookies()).set(cookieName, sessionId, {
- httpOnly: true,
- secure:
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS),
- sameSite: "lax",
- maxAge: 30 * 24 * 60 * 60,
- path: "/",
- });
+ await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
const userChecklistDir = path.join(
process.cwd(),
@@ -142,10 +215,13 @@ export const login = async (formData: FormData) => {
}
const usersFile = path.join(process.cwd(), "data", "users", "users.json");
+ await fs.mkdir(path.dirname(usersFile), { recursive: true });
+ try { await fs.access(usersFile); } catch { await fs.writeFile(usersFile, "[]", "utf-8"); }
await lock(usersFile);
+ let lockReleased = false;
try {
- const users = await readJsonFile(USERS_FILE);
+ const users = (await readJsonFile(USERS_FILE)) || [];
const user = users.find(
(u: User) => u.username.toLowerCase() === username.toLowerCase(),
);
@@ -176,79 +252,44 @@ export const login = async (formData: FormData) => {
}
}
- if (!user || user.passwordHash !== hashPassword(password)) {
- if (user && !bruteforceProtectionDisabled) {
- const userIndex = users.findIndex(
- (u: User) => u.username.toLowerCase() === username.toLowerCase(),
- );
+ if (getAuthMode() === "ldap") {
+ const ldapResult = await ldapLogin(username, password);
+
+ if (!ldapResult.ok) {
+ if (ldapResult.kind === "connection_error") {
+ await logAuthEvent("login", username, false, "LDAP connection error");
+ return { error: "Authentication service unavailable" };
+ }
- if (userIndex !== -1) {
- const failedAttempts =
- (users[userIndex].failedLoginAttempts || 0) + 1;
- users[userIndex].failedLoginAttempts = failedAttempts;
-
- const delayMs = _youShallNotPass(failedAttempts);
- let lockedUntil: string | undefined;
- if (delayMs > 0) {
- lockedUntil = new Date(Date.now() + delayMs).toISOString();
- users[userIndex].nextAllowedLoginAttempt = lockedUntil;
- } else {
- users[userIndex].nextAllowedLoginAttempt = undefined;
- }
-
- await writeJsonFile(users, USERS_FILE);
-
- await logAuthEvent(
- "login",
- username,
- false,
- `Invalid credentials - attempt ${failedAttempts}`,
- );
-
- const attemptsRemaining = Math.max(0, 4 - failedAttempts);
- const waitSeconds = delayMs > 0 ? Math.ceil(delayMs / 1000) : 0;
-
- return {
- error:
- delayMs > 0
- ? "Too many failed attempts"
- : "Invalid username or password",
- attemptsRemaining,
- failedAttempts,
- ...(lockedUntil && { lockedUntil, waitSeconds }),
- };
+ if (ldapResult.kind === "unauthorized") {
+ lockReleased = true;
+ await unlock(usersFile);
+ redirect("/auth/login?error=unauthorized");
}
+
+ return await _handleFailedLogin(users, username, bruteforceProtectionDisabled);
}
- await logAuthEvent(
- "login",
- username,
- false,
- "Invalid username or password",
- );
- return { error: "Invalid username or password" };
+ lockReleased = true;
+ await unlock(usersFile);
+
+ await ensureUser(ldapResult.username, ldapResult.isAdmin);
+
+ const ldapSessionId = _generateSessionId();
+ await createSession(ldapSessionId, ldapResult.username, "ldap");
+ await _setSessionCookie(ldapSessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
+ await logAuthEvent("login", ldapResult.username, true);
+
+ redirect("/");
}
- if (user.mfaEnabled) {
- const pendingSessionId = createHash("sha256")
- .update(`pending-mfa-${Math.random().toString()}`)
- .digest("hex");
-
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-mfa-pending"
- : "mfa-pending";
-
- (await cookies()).set(cookieName, pendingSessionId, {
- httpOnly: true,
- secure:
- process.env.NODE_ENV === "production" &&
- isEnvEnabled(process.env.HTTPS),
- sameSite: "lax",
- maxAge: 10 * 60,
- path: "/",
- });
+ if (!user || user.passwordHash !== hashPassword(password)) {
+ return await _handleFailedLogin(users, username, bruteforceProtectionDisabled);
+ }
+ if (user.mfaEnabled) {
+ const pendingSessionId = _generateSessionId();
+ await _setSessionCookie(pendingSessionId, getMfaPendingCookieName(), 10 * 60);
await createSession(pendingSessionId, user.username, "pending-mfa");
redirect("/auth/verify-mfa");
@@ -264,47 +305,27 @@ export const login = async (formData: FormData) => {
await writeJsonFile(users, USERS_FILE);
}
- const sessionId = createHash("sha256")
- .update(Math.random().toString())
- .digest("hex");
+ const sessionId = _generateSessionId();
const sessions = await readSessions();
sessions[sessionId] = user.username;
await writeSessions(sessions);
await createSession(sessionId, user.username, "local");
-
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
-
- (await cookies()).set(cookieName, sessionId, {
- httpOnly: true,
- secure:
- process.env.NODE_ENV === "production" &&
- isEnvEnabled(process.env.HTTPS),
- sameSite: "lax",
- maxAge: 30 * 24 * 60 * 60,
- path: "/",
- });
-
+ await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
await logAuthEvent("login", user.username, true);
redirect("/");
} finally {
- await unlock(usersFile);
+ if (!lockReleased) {
+ await unlock(usersFile);
+ }
}
};
export const logout = async () => {
const username = await getUsername();
-
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
-
+ const cookieName = getSessionCookieName();
const sessionId = (await cookies()).get(cookieName)?.value;
if (sessionId) {
@@ -324,7 +345,7 @@ export const logout = async () => {
await logAuthEvent("logout", username || "unknown", true);
- if (process.env.SSO_MODE === "oidc") {
+ if (getAuthMode() === "oidc") {
redirect("/api/oidc/logout");
} else {
redirect("/auth/login");
@@ -339,10 +360,7 @@ export const verifyMfaLogin = async (formData: FormData) => {
return { error: "Code is required" };
}
- const pendingCookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-mfa-pending"
- : "mfa-pending";
+ const pendingCookieName = getMfaPendingCookieName();
const pendingSessionId = (await cookies()).get(pendingCookieName)?.value;
@@ -406,9 +424,7 @@ export const verifyMfaLogin = async (formData: FormData) => {
await writeJsonFile(users, USERS_FILE);
}
- const sessionId = createHash("sha256")
- .update(Math.random().toString())
- .digest("hex");
+ const sessionId = _generateSessionId();
sessions[sessionId] = username;
delete sessions[pendingSessionId];
@@ -418,20 +434,7 @@ export const verifyMfaLogin = async (formData: FormData) => {
await createSession(sessionId, username, "local");
(await cookies()).delete(pendingCookieName);
-
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
-
- (await cookies()).set(cookieName, sessionId, {
- httpOnly: true,
- secure:
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS),
- sameSite: "lax",
- maxAge: 30 * 24 * 60 * 60,
- path: "/",
- });
+ await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
await logAuthEvent("login", username, true);
diff --git a/app/_server/actions/auth/ldap.ts b/app/_server/actions/auth/ldap.ts
new file mode 100644
index 00000000..3059a3a0
--- /dev/null
+++ b/app/_server/actions/auth/ldap.ts
@@ -0,0 +1,129 @@
+import { Client } from "ldapts";
+import { getEnvOrFile } from "@/app/_server/actions/file";
+
+export type LdapLoginResult =
+ | { ok: true; username: string; isAdmin: boolean }
+ | { ok: false; kind: "invalid_credentials" }
+ | { ok: false; kind: "unauthorized" }
+ | { ok: false; kind: "connection_error" };
+
+function escapeLdapFilter(value: string): string {
+ return value
+ .replace(/\\/g, "\\5c")
+ .replace(/\*/g, "\\2a")
+ .replace(/\(/g, "\\28")
+ .replace(/\)/g, "\\29")
+ .replace(/\0/g, "\\00");
+}
+
+export async function ldapLogin(
+ username: string,
+ password: string,
+): Promise
{
+ const url = process.env.LDAP_URL;
+ const bindDN = process.env.LDAP_BIND_DN;
+ const bindPassword = await getEnvOrFile(
+ "LDAP_BIND_PASSWORD",
+ "LDAP_BIND_PASSWORD_FILE",
+ );
+ const baseDN = process.env.LDAP_BASE_DN;
+ const userAttribute = process.env.LDAP_USER_ATTRIBUTE || "uid";
+
+ if (!url || !bindDN || !bindPassword || !baseDN) {
+ if (process.env.DEBUGGER) {
+ console.error("LDAP - Missing required configuration:", {
+ url: !!url,
+ bindDN: !!bindDN,
+ bindPassword: !!bindPassword,
+ baseDN: !!baseDN,
+ });
+ }
+ return { ok: false, kind: "connection_error" };
+ }
+
+ const client = new Client({ url });
+
+ try {
+ try {
+ await client.bind(bindDN, bindPassword);
+ } catch (err) {
+ if (process.env.DEBUGGER) {
+ console.error("LDAP - Service account bind failed:", err);
+ }
+ return { ok: false, kind: "connection_error" };
+ }
+
+ let userDN = "";
+ let memberOf: string[] = [];
+
+ try {
+ const { searchEntries } = await client.search(baseDN, {
+ filter: `(${userAttribute}=${escapeLdapFilter(username)})`,
+ attributes: ["dn", "memberOf"],
+ });
+
+ if (searchEntries.length === 0) {
+ return { ok: false, kind: "invalid_credentials" };
+ }
+
+ const entry = searchEntries[0];
+ userDN = entry.dn;
+
+ const raw = entry["memberOf"];
+ if (Array.isArray(raw)) {
+ memberOf = raw as string[];
+ } else if (typeof raw === "string") {
+ memberOf = [raw];
+ }
+ } catch (err) {
+ if (process.env.DEBUGGER) {
+ console.error("LDAP - User search failed:", err);
+ }
+ return { ok: false, kind: "connection_error" };
+ }
+
+ try {
+ await client.bind(userDN, password);
+ } catch (err) {
+ return { ok: false, kind: "invalid_credentials" };
+ }
+
+ const adminGroupList = (process.env.LDAP_ADMIN_GROUPS || "")
+ .split("|")
+ .map((g) => g.trim())
+ .filter(Boolean);
+
+ const userGroupList = (process.env.LDAP_USER_GROUPS || "")
+ .split("|")
+ .map((g) => g.trim())
+ .filter(Boolean);
+
+ const isAdmin =
+ adminGroupList.length > 0 &&
+ adminGroupList.some((g) => memberOf.includes(g));
+
+ if (userGroupList.length > 0) {
+ const isInUserGroup = userGroupList.some((g) => memberOf.includes(g));
+ if (!isInUserGroup && !isAdmin) {
+ if (process.env.DEBUGGER) {
+ console.log("LDAP - User not in allowed groups:", {
+ username,
+ requiredGroups: process.env.LDAP_USER_GROUPS,
+ memberOf,
+ });
+ }
+ return { ok: false, kind: "unauthorized" };
+ }
+ }
+
+ if (process.env.DEBUGGER) {
+ console.log("LDAP - Login successful:", { username, isAdmin });
+ }
+
+ return { ok: true, username, isAdmin };
+ } finally {
+ try {
+ await client.unbind();
+ } catch {}
+ }
+}
diff --git a/app/_server/actions/session/index.ts b/app/_server/actions/session/index.ts
index 3c16bc21..c5b91b18 100644
--- a/app/_server/actions/session/index.ts
+++ b/app/_server/actions/session/index.ts
@@ -7,7 +7,7 @@ import { readJsonFile, writeJsonFile } from "../file";
import { SESSION_DATA_FILE, SESSIONS_FILE } from "@/app/_consts/files";
import { getCurrentUser } from "../users";
import { logAuthEvent } from "@/app/_server/actions/log";
-import { isEnvEnabled } from "@/app/_utils/env-utils";
+import { getSessionCookieName } from "@/app/_utils/env-utils";
export interface SessionData {
id: string;
@@ -16,7 +16,7 @@ export interface SessionData {
ipAddress: string;
createdAt: string;
lastActivity: string;
- loginType?: "local" | "sso" | "pending-mfa";
+ loginType?: "local" | "sso" | "ldap" | "pending-mfa";
}
export interface Session {
@@ -48,7 +48,7 @@ export const readSessions = async (): Promise => {
export const createSession = async (
sessionId: string,
username: string,
- loginType: "local" | "sso" | "pending-mfa",
+ loginType: "local" | "sso" | "pending-mfa" | "ldap",
): Promise => {
const headersList = await headers();
const userAgent = headersList.get("user-agent") || "Unknown";
@@ -104,14 +104,12 @@ export const getSessionsForUser = async (
export const getSessionId = async (): Promise => {
const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
+getSessionCookieName();
return (await cookies()).get(cookieName)?.value || "";
};
export const getLoginType = async (): Promise<
- "local" | "sso" | "pending-mfa" | undefined
+ "local" | "sso" | "pending-mfa" | "ldap" | undefined
> => {
const sessionId = await getSessionId();
if (!sessionId) return undefined;
@@ -214,11 +212,7 @@ export const terminateAllOtherSessions = async (): Promise> => {
};
}
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
- const sessionId = (await cookies()).get(cookieName)?.value;
+ const sessionId = (await cookies()).get(getSessionCookieName())?.value;
await removeAllSessionsForUser(currentUser.username, sessionId);
diff --git a/app/_server/actions/users/ensure-user.ts b/app/_server/actions/users/ensure-user.ts
new file mode 100644
index 00000000..fffefd00
--- /dev/null
+++ b/app/_server/actions/users/ensure-user.ts
@@ -0,0 +1,91 @@
+"use server";
+import { lock, unlock } from "proper-lockfile";
+import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+import { NOTES_FOLDER } from "@/app/_consts/notes";
+import { isDebugFlag } from "@/app/_utils/env-utils";
+import fs from "fs/promises";
+import path from "path";
+
+const debugProxy = isDebugFlag("proxy");
+
+export async function ensureUser(
+ username: string,
+ isAdmin: boolean,
+): Promise {
+ const usersFile = path.join(process.cwd(), "data", "users", "users.json");
+ await fs.mkdir(path.dirname(usersFile), { recursive: true });
+
+ await lock(usersFile);
+ try {
+ let users: any[] = [];
+ try {
+ const content = await fs.readFile(usersFile, "utf-8");
+ if (content) {
+ users = JSON.parse(content);
+ }
+ } catch {}
+
+ if (users.length === 0) {
+ users.push({
+ username,
+ passwordHash: "",
+ isAdmin: true,
+ isSuperAdmin: true,
+ createdAt: new Date().toISOString(),
+ });
+ if (debugProxy) {
+ console.log(
+ "SSO CALLBACK - Created first user as super admin:",
+ username,
+ );
+ }
+ } else {
+ const existing = users.find((u) => u.username === username);
+ if (!existing) {
+ users.push({
+ username,
+ passwordHash: "",
+ isAdmin,
+ createdAt: new Date().toISOString(),
+ });
+ if (debugProxy) {
+ console.log("SSO CALLBACK - Created new user:", {
+ username,
+ isAdmin,
+ });
+ }
+ } else {
+ const wasAdmin = existing.isAdmin;
+ if (isAdmin && !existing.isAdmin) {
+ existing.isAdmin = true;
+ if (debugProxy) {
+ console.log("SSO CALLBACK - Updated existing user to admin:", {
+ username,
+ wasAdmin,
+ nowAdmin: true,
+ });
+ }
+ } else if (debugProxy) {
+ console.log("SSO CALLBACK - User already exists:", {
+ username,
+ currentIsAdmin: existing.isAdmin,
+ requestedAdmin: isAdmin,
+ });
+ }
+ }
+ }
+ await fs.writeFile(usersFile, JSON.stringify(users, null, 2));
+ } finally {
+ await unlock(usersFile);
+ }
+
+ const checklistDir = path.join(
+ process.cwd(),
+ "data",
+ CHECKLISTS_FOLDER,
+ username,
+ );
+ const notesDir = path.join(process.cwd(), "data", NOTES_FOLDER, username);
+ await fs.mkdir(checklistDir, { recursive: true });
+ await fs.mkdir(notesDir, { recursive: true });
+}
diff --git a/app/_server/actions/users/index.ts b/app/_server/actions/users/index.ts
index 46a7b55b..3ba4f4a7 100644
--- a/app/_server/actions/users/index.ts
+++ b/app/_server/actions/users/index.ts
@@ -1,5 +1,7 @@
export type { UserUpdatePayload } from "./crud";
+export { ensureUser } from "./ensure-user";
+
export {
createUser,
deleteUser,
@@ -20,15 +22,9 @@ export {
getUserByNote,
} from "./queries";
-export {
- isAuthenticated,
- isAdmin,
- canAccessAllContent,
-} from "./auth";
+export { isAuthenticated, isAdmin, canAccessAllContent } from "./auth";
-export {
- updateUserSettings,
-} from "./settings";
+export { updateUserSettings } from "./settings";
export {
getUserIndex,
diff --git a/app/_types/user.ts b/app/_types/user.ts
index 56b0e40e..e0883aba 100644
--- a/app/_types/user.ts
+++ b/app/_types/user.ts
@@ -83,7 +83,7 @@ export interface Session {
ipAddress: string;
lastActivity: string;
isCurrent: boolean;
- loginType?: "local" | "sso" | "pending-mfa";
+ loginType?: "local" | "sso" | "pending-mfa" | "ldap";
}
export type SanitisedUser = Omit<
diff --git a/app/_utils/env-utils.ts b/app/_utils/env-utils.ts
index 156db588..c5d927c1 100644
--- a/app/_utils/env-utils.ts
+++ b/app/_utils/env-utils.ts
@@ -8,6 +8,19 @@ export function isEnvEnabled(value: string | boolean | undefined): boolean {
return lower !== "no" && lower !== "false" && lower !== "0";
}
+export const isSecureEnv = (): boolean =>
+ process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS);
+
+export const getSessionCookieName = (): string =>
+ isSecureEnv() ? "__Host-session" : "session";
+
+export const getMfaPendingCookieName = (): string =>
+ isSecureEnv() ? "__Host-mfa-pending" : "mfa-pending";
+
+// @deprecated SSO_MODE is deprecated, use AUTH_MODE instead
+export const getAuthMode = (): string | undefined =>
+ process.env.AUTH_MODE || process.env.SSO_MODE;
+
export function isDebugFlag(flag: string): boolean {
const v = process.env.DEBUGGER;
if (!v || typeof v !== "string") return false;
diff --git a/app/api/auth/check-session/route.ts b/app/api/auth/check-session/route.ts
index c0d5daa7..7fb3158f 100644
--- a/app/api/auth/check-session/route.ts
+++ b/app/api/auth/check-session/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { readSessions } from "@/app/_server/actions/session";
-import { isEnvEnabled } from "@/app/_utils/env-utils";
+import { getSessionCookieName } from "@/app/_utils/env-utils";
type Session = Record;
@@ -10,11 +10,7 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const cookieStore = await cookies();
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
- const sessionId = cookieStore.get(cookieName)?.value;
+ const sessionId = cookieStore.get(getSessionCookieName())?.value;
if (!sessionId) {
return new NextResponse(JSON.stringify({ error: "No session cookie" }), {
diff --git a/app/api/oidc/callback/route.ts b/app/api/oidc/callback/route.ts
index 112a6739..74dc8d15 100644
--- a/app/api/oidc/callback/route.ts
+++ b/app/api/oidc/callback/route.ts
@@ -3,9 +3,9 @@ import crypto from "crypto";
import fs from "fs/promises";
import path from "path";
import { getEnvOrFile } from "@/app/_server/actions/file";
-import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
-import { NOTES_FOLDER } from "@/app/_consts/notes";
import { lock, unlock } from "proper-lockfile";
+import { getAuthMode } from "@/app/_utils/env-utils";
+import { ensureUser } from "@/app/_server/actions/users";
import { jwtVerify, createRemoteJWKSet, decodeJwt } from "jose";
import { createSession } from "@/app/_server/actions/session";
import {
@@ -14,7 +14,7 @@ import {
} from "@/app/_server/actions/file";
import { USERS_FILE } from "@/app/_consts/files";
import { logAudit } from "@/app/_server/actions/log";
-import { isEnvEnabled, isDebugFlag } from "@/app/_utils/env-utils";
+import { isDebugFlag, isSecureEnv, getSessionCookieName } from "@/app/_utils/env-utils";
const debugProxy = isDebugFlag("proxy");
@@ -49,89 +49,11 @@ function checkClaims(
return isAllowed;
}
-async function ensureUser(username: string, isAdmin: boolean) {
- const usersFile = path.join(process.cwd(), "data", "users", "users.json");
- await fs.mkdir(path.dirname(usersFile), { recursive: true });
-
- await lock(usersFile);
- try {
- let users: any[] = [];
- try {
- const content = await fs.readFile(usersFile, "utf-8");
- if (content) {
- users = JSON.parse(content);
- }
- } catch {}
-
- if (users.length === 0) {
- users.push({
- username,
- passwordHash: "",
- isAdmin: true,
- isSuperAdmin: true,
- createdAt: new Date().toISOString(),
- });
- if (debugProxy) {
- console.log(
- "SSO CALLBACK - Created first user as super admin:",
- username,
- );
- }
- } else {
- const existing = users.find((u) => u.username === username);
- if (!existing) {
- users.push({
- username,
- passwordHash: "",
- isAdmin,
- createdAt: new Date().toISOString(),
- });
- if (debugProxy) {
- console.log("SSO CALLBACK - Created new user:", {
- username,
- isAdmin,
- });
- }
- } else {
- const wasAdmin = existing.isAdmin;
- if (isAdmin && !existing.isAdmin) {
- existing.isAdmin = true;
- if (debugProxy) {
- console.log("SSO CALLBACK - Updated existing user to admin:", {
- username,
- wasAdmin,
- nowAdmin: true,
- });
- }
- } else if (debugProxy) {
- console.log("SSO CALLBACK - User already exists:", {
- username,
- currentIsAdmin: existing.isAdmin,
- requestedAdmin: isAdmin,
- });
- }
- }
- }
- await fs.writeFile(usersFile, JSON.stringify(users, null, 2));
- } finally {
- await unlock(usersFile);
- }
-
- const checklistDir = path.join(
- process.cwd(),
- "data",
- CHECKLISTS_FOLDER,
- username,
- );
- const notesDir = path.join(process.cwd(), "data", NOTES_FOLDER, username);
- await fs.mkdir(checklistDir, { recursive: true });
- await fs.mkdir(notesDir, { recursive: true });
-}
export async function GET(request: NextRequest) {
const appUrl = process.env.APP_URL || request.nextUrl.origin;
- if (process.env.SSO_MODE !== "oidc") {
+ if (getAuthMode() !== "oidc") {
return NextResponse.redirect(`${appUrl}/auth/login`);
}
@@ -429,15 +351,10 @@ export async function GET(request: NextRequest) {
await ensureUser(username, isAdmin);
const sessionId = base64UrlEncode(crypto.randomBytes(32));
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
const response = NextResponse.redirect(`${appUrl}/`);
- response.cookies.set(cookieName, sessionId, {
+ response.cookies.set(getSessionCookieName(), sessionId, {
httpOnly: true,
- secure:
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS),
+ secure: isSecureEnv(),
sameSite: "lax",
path: "/",
maxAge: 30 * 24 * 60 * 60,
diff --git a/app/api/oidc/login/route.ts b/app/api/oidc/login/route.ts
index f6930d3d..f376f1c7 100644
--- a/app/api/oidc/login/route.ts
+++ b/app/api/oidc/login/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { getEnvOrFile } from "@/app/_server/actions/file";
-import { isEnvEnabled, isDebugFlag } from "@/app/_utils/env-utils";
+import { isEnvEnabled, isDebugFlag, getAuthMode } from "@/app/_utils/env-utils";
const debugProxy = isDebugFlag("proxy");
@@ -20,10 +20,10 @@ function sha256(input: string) {
}
export async function GET(request: NextRequest) {
- const ssoMode = process.env.SSO_MODE;
+ const authMode = getAuthMode();
const appUrl = process.env.APP_URL || request.nextUrl.origin;
- if (ssoMode && ssoMode?.toLowerCase() !== "oidc") {
+ if (authMode && authMode?.toLowerCase() !== "oidc") {
if (debugProxy) {
console.log("SSO LOGIN - ssoMode is not oidc");
}
diff --git a/app/api/oidc/logout/route.ts b/app/api/oidc/logout/route.ts
index 74be6e5a..4cbc346a 100644
--- a/app/api/oidc/logout/route.ts
+++ b/app/api/oidc/logout/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { isDebugFlag } from "@/app/_utils/env-utils";
+import { isDebugFlag, getAuthMode } from "@/app/_utils/env-utils";
const debugProxy = isDebugFlag("proxy");
@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
console.log("OIDC LOGOUT - appUrl:", appUrl);
}
- if (process.env.SSO_MODE && process.env.SSO_MODE?.toLowerCase() !== "oidc") {
+ const authMode = getAuthMode();
+ if (authMode && authMode?.toLowerCase() !== "oidc") {
if (debugProxy) {
console.log("SSO LOGOUT - ssoMode is not oidc, redirecting to login");
}
diff --git a/docker-compose.yml b/docker-compose.yml
index a53f6879..4e564798 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,7 +21,7 @@ services:
#- INTERNAL_API_URL=http://localhost:3000
#- SERVE_PUBLIC_IMAGES=yes
#- SERVE_PUBLIC_FILES=yes
- #- SSO_MODE=oidc
+ #- AUTH_MODE=oidc
#- OIDC_ISSUER=
#- OIDC_CLIENT_ID=
#- OIDC_CLIENT_SECRET=your_client_secret
diff --git a/howto/DOCKER.md b/howto/DOCKER.md
index 9d835206..ef5ae3a8 100644
--- a/howto/DOCKER.md
+++ b/howto/DOCKER.md
@@ -102,7 +102,7 @@ environment:
- SERVE_PUBLIC_IMAGES=yes
- SERVE_PUBLIC_FILES=yes
- STOP_CHECK_UPDATES=no
- - SSO_MODE=oidc
+ - AUTH_MODE=oidc
- OIDC_ISSUER=
- OIDC_CLIENT_ID=
- APP_URL=https://your-jotty-domain.com
@@ -124,7 +124,7 @@ environment:
### SSO Configuration (Optional)
-- `- SSO_MODE=oidc` Enables OIDC (OpenID Connect) single sign-on authentication.
+- `- AUTH_MODE=oidc` Enables OIDC (OpenID Connect) single sign-on authentication. (Previously `SSO_MODE`, which still works as a fallback.)
- `- OIDC_ISSUER=` URL of your OIDC provider (e.g., Authentik, Auth0, Keycloak).
- `- OIDC_CLIENT_ID=` Client ID from your OIDC provider configuration.
- `- OIDC_CLIENT_SECRET=your_client_secret` Optional. Client secret for confidential OIDC client authentication.
diff --git a/howto/ENV-VARIABLES.md b/howto/ENV-VARIABLES.md
index 97299050..9de0e1ef 100644
--- a/howto/ENV-VARIABLES.md
+++ b/howto/ENV-VARIABLES.md
@@ -9,7 +9,7 @@ HTTPS=true
SERVE_PUBLIC_IMAGES=yes
SERVE_PUBLIC_FILES=yes
STOP_CHECK_UPDATES=no
-SSO_MODE=oidc
+AUTH_MODE=oidc
OIDC_ISSUER=
OIDC_CLIENT_ID=
APP_URL=https://your-jotty-domain.com
@@ -43,7 +43,7 @@ OIDC_ADMIN_GROUPS=admins
### Mandatory
- `APP_URL=https://your-jotty-domain.com` Tells the OIDC of your choice what url you are trying to authenticate against.
-- `SSO_MODE=oidc` Enables OIDC (OpenID Connect) single sign-on authentication.
+- `AUTH_MODE=oidc` Enables OIDC (OpenID Connect) single sign-on authentication. (Previously `SSO_MODE`, which still works as a fallback.)
- `OIDC_ISSUER=` URL of your OIDC provider (e.g., Authentik, Auth0, Keycloak).
- `OIDC_CLIENT_ID=` Client ID from your OIDC provider configuration.
diff --git a/howto/LDAP.md b/howto/LDAP.md
new file mode 100644
index 00000000..d3fcfb89
--- /dev/null
+++ b/howto/LDAP.md
@@ -0,0 +1,176 @@
+# LDAP Authentication
+
+`jotty·page` supports authenticating users directly against an LDAP or Active Directory server. Users enter their usual username and password in the standard login form — no browser redirects.
+
+## Requirements
+
+- An LDAP server reachable from the container (OpenLDAP, LLDAP, Active Directory, FreeIPA, etc.)
+- A service account DN ("bind user") and password with search access to the user tree
+- For group-based access control or admin promotion: the **`memberof` overlay** must be enabled on the LDAP server. Without it, `LDAP_USER_GROUPS` and `LDAP_ADMIN_GROUPS` will silently have no effect (all authenticated users will be allowed in, none will be promoted to admin).
+
+## Quick Start
+
+```yaml
+services:
+ jotty:
+ environment:
+ - AUTH_MODE=ldap
+ - LDAP_URL=ldap://ldap.example.com:389
+ - LDAP_BIND_DN=cn=service,dc=example,dc=com
+ - LDAP_BIND_PASSWORD=service-account-password
+ - LDAP_BASE_DN=ou=users,dc=example,dc=com
+```
+
+## Environment Variables
+
+### Required
+
+- `AUTH_MODE=ldap` — Enables LDAP authentication. The standard username/password form is used; the OIDC "Sign in with SSO" button is not shown. (Previously `SSO_MODE=ldap`, which still works as a fallback.)
+- `LDAP_URL=ldap://ldap.example.com:389` — URL of your LDAP server. Use `ldaps://` on port `636` for TLS.
+- `LDAP_BIND_DN=cn=service,dc=example,dc=com` — Distinguished name of the service account used to search the directory.
+- `LDAP_BIND_PASSWORD=secret` — Password of the service account. See [Docker Secrets](#docker-secrets) for storing this securely.
+- `LDAP_BASE_DN=ou=users,dc=example,dc=com` — Base DN under which to search for users.
+
+### Optional
+
+- `SSO_FALLBACK_LOCAL=yes` — Allow both LDAP and local password login simultaneously. When set, the standard form accepts both LDAP credentials and local accounts. Useful for a local admin fallback.
+- `LDAP_USER_ATTRIBUTE=uid` — The LDAP attribute matched against the submitted username. Defaults to `uid`. Use `sAMAccountName` for Active Directory.
+- `LDAP_ADMIN_GROUPS=cn=admins,ou=groups,dc=example,dc=com` — Pipe-separated list of group DNs. Users whose `memberOf` attribute contains any of these DNs are granted admin rights on first login. **Note:** DNs contain commas, so groups must be separated with `|`, not `,`.
+- `LDAP_USER_GROUPS=cn=jotty,ou=groups,dc=example,dc=com` — Pipe-separated list of group DNs. When set, only members of these groups (or admin groups) can log in. Users not in any listed group are rejected with an "unauthorized" error.
+- `LDAP_BIND_PASSWORD_FILE=/run/secrets/ldap_password` — Path to a file containing the service account password. Takes priority over `LDAP_BIND_PASSWORD` if both are set. See [Docker Secrets](#docker-secrets).
+
+## Group-Based Access Control
+
+Both `LDAP_USER_GROUPS` and `LDAP_ADMIN_GROUPS` are matched against the `memberOf` attribute of the authenticated user's LDAP entry. This is a multi-value attribute listing all group DNs the user belongs to, for example:
+
+```
+uid: alice
+memberOf: cn=admins,ou=groups,dc=example,dc=com
+memberOf: cn=jotty,ou=groups,dc=example,dc=com
+```
+
+**Access logic:**
+- If `LDAP_USER_GROUPS` is set: the user must be in at least one listed group, **or** in an `LDAP_ADMIN_GROUPS` group. Admin group membership bypasses the user group check.
+- If `LDAP_USER_GROUPS` is not set: all successfully authenticated LDAP users are allowed in.
+- Admin status (`LDAP_ADMIN_GROUPS`) is applied on first login and can be promoted later (e.g. by also adding the user to the admin group). It is never automatically revoked.
+
+**Requirement:** The `memberof` overlay must be enabled on your LDAP server. On OpenLDAP this is the `memberof` module. Active Directory supports this natively. FreeIPA supports it with the `memberof` plugin. If the overlay is absent, `memberOf` will not appear on user entries and group checks will not work.
+
+## Active Directory Notes
+
+For Active Directory, set:
+
+```yaml
+- LDAP_USER_ATTRIBUTE=sAMAccountName
+- LDAP_URL=ldap://dc.example.com:389
+- LDAP_BASE_DN=CN=Users,DC=example,DC=com
+- LDAP_BIND_DN=CN=service,CN=Users,DC=example,DC=com
+```
+
+`memberOf` is supported natively in Active Directory — no extra configuration needed for group-based access control.
+
+## LDAPS (TLS)
+
+Use `ldaps://` and port `636`:
+
+```yaml
+- LDAP_URL=ldaps://ldap.example.com:636
+```
+
+`ldapts` validates the server certificate against the system CA store by default. If you are using a self-signed certificate, you need to add your CA certificate to the container. The recommended approach is to mount it and set `NODE_EXTRA_CA_CERTS`:
+
+```yaml
+services:
+ jotty:
+ environment:
+ - NODE_EXTRA_CA_CERTS=/app/config/ldap-ca.crt
+ volumes:
+ - ./ldap-ca.crt:/app/config/ldap-ca.crt:ro
+```
+
+## Docker Secrets
+
+
+Storing the service account password securely
+
+To prevent the service account password from appearing in `docker inspect` output, use a secrets file:
+
+```yaml
+services:
+ jotty:
+ environment:
+ - AUTH_MODE=ldap
+ - LDAP_URL=ldap://ldap.example.com:389
+ - LDAP_BIND_DN=cn=service,dc=example,dc=com
+ - LDAP_BIND_PASSWORD_FILE=/run/secrets/ldap_password
+ - LDAP_BASE_DN=ou=users,dc=example,dc=com
+ secrets:
+ - ldap_password
+
+secrets:
+ ldap_password:
+ file: ./secrets/ldap_password.txt
+```
+
+```bash
+mkdir secrets
+echo "your-service-account-password" > secrets/ldap_password.txt
+chmod 600 secrets/ldap_password.txt
+```
+
+
+
+## Limitations
+
+jotty·page manages a **local copy** of user accounts. LDAP is only consulted at login time. This means:
+
+- Changing a user's password or deleting a user in jotty's admin panel does **not** propagate to the LDAP server.
+- Changing a password in jotty's personal settings has no effect on LDAP login — the user always authenticates against LDAP.
+- Deleting a user in jotty removes their local notes and checklists, but does not remove them from the directory.
+- Admin status is set from `LDAP_ADMIN_GROUPS` on first login. Removing a user from the admin group in LDAP will not revoke their admin status in jotty (though a jotty admin can do so manually).
+
+## Troubleshooting
+
+### Disclaimer
+
+LDAP support was implemented with the help of Claude code. It was tested under the following conditions:
+ * LDAP server: [lldap](https://github.com/lldap/lldap)
+ * Environment variables: all where tested except `LDAP_BIND_PASSWORD_FILE` and `LDAP_USER_ATTRIBUTE`.
+ * Group-based access control: the logic described above was verified.
+ * Correct first start behaviour was verified.
+
+Other LDAP servers have not been tested, yet.
+
+### Login fails with "Authentication service unavailable"
+
+The service account bind or the LDAP search failed (connection refused, DNS failure, wrong URL). Set `DEBUGGER=true` in your environment to log the underlying error to stdout:
+
+```yaml
+- DEBUGGER=true
+```
+
+### Login fails with "Invalid username or password" but credentials are correct
+
+- Check that `LDAP_USER_ATTRIBUTE` matches the attribute used for login. OpenLDAP typically uses `uid`; Active Directory uses `sAMAccountName`.
+- Check that `LDAP_BASE_DN` covers the user's location in the directory tree.
+- Verify the service account has read access to the base DN.
+
+### Login fails with "You are not authorized"
+
+The user authenticated successfully but is not a member of any group listed in `LDAP_USER_GROUPS`. Either add the user to a permitted group in the directory, or remove `LDAP_USER_GROUPS` to allow all authenticated users.
+
+### Group membership is not being detected
+
+Ensure the `memberof` overlay is enabled on your LDAP server. You can verify by checking the user's entry directly:
+
+```bash
+ldapsearch -x -H ldap://ldap.example.com \
+ -D "cn=service,dc=example,dc=com" -w secret \
+ -b "ou=users,dc=example,dc=com" "(uid=alice)" memberOf
+```
+
+If `memberOf` does not appear in the output, the overlay is not active.
+
+### First LDAP user is not getting admin rights
+
+`LDAP_ADMIN_GROUPS` is only evaluated when a user's `memberOf` contains a matching DN. If no admin groups are configured, no LDAP user will be automatically promoted to admin. You can manually grant admin rights from jotty's admin panel, or set `SSO_FALLBACK_LOCAL=yes` and create a local admin account during initial setup.
diff --git a/howto/SSO.md b/howto/SSO.md
index 9ef921c6..499ad498 100644
--- a/howto/SSO.md
+++ b/howto/SSO.md
@@ -27,7 +27,7 @@
services:
jotty:
environment:
- - SSO_MODE=oidc
+ - AUTH_MODE=oidc
- OIDC_ISSUER=https://YOUR_SSO_HOST/issuer/path
- OIDC_CLIENT_ID=your_client_id
- APP_URL=https://your-jotty-domain.com # if not set defaults to http://localhost:
@@ -131,7 +131,7 @@ For enhanced security in production environments, you can store OIDC credentials
services:
jotty:
environment:
- - SSO_MODE=oidc
+ - AUTH_MODE=oidc
- OIDC_ISSUER=https://YOUR_SSO_HOST/issuer/path
- OIDC_CLIENT_ID_FILE=/run/secrets/oidc_client_id
- OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
diff --git a/next.config.mjs b/next.config.mjs
index 7e7868f1..5b039ebf 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -6,7 +6,7 @@ const withNextIntl = createNextIntlPlugin("./i18n.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
- serverExternalPackages: ["ws"],
+ serverExternalPackages: ["ws", "libsodium-wrappers-sumo"],
serverActions: {
bodySizeLimit: "100mb",
},
diff --git a/package.json b/package.json
index dd3d9ad2..694e8236 100644
--- a/package.json
+++ b/package.json
@@ -55,11 +55,12 @@
"diff": "^5.2.2",
"dompurify": "^3.3.0",
"hast-util-to-html": "^9.0.5",
- "hugeicons-react": "^0.3.0",
+ "hugeicons-react": "0.3.0",
"jose": "^6.1.0",
"js-beautify": "^1.15.4",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
+ "ldapts": "^8.1.7",
"libsodium-wrappers-sumo": "^0.7.15",
"mermaid": "^11.12.1",
"next": "16.1.6",
@@ -102,7 +103,6 @@
"lodash-es": "^4.17.23",
"lodash": "^4.17.23",
"nanoid": "^3.3.8",
- "**/mermaid": "^11.12.1",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"@isaacs/brace-expansion": "^5.0.1",
diff --git a/proxy.ts b/proxy.ts
index 56e56c86..67ecb2e4 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
-import { isEnvEnabled, isDebugFlag } from "./app/_utils/env-utils";
+import { isDebugFlag, getSessionCookieName } from "./app/_utils/env-utils";
const debugProxy = isDebugFlag("proxy");
@@ -59,11 +59,7 @@ export const proxy = async (request: NextRequest) => {
return NextResponse.next();
}
- const cookieName =
- process.env.NODE_ENV === "production" && isEnvEnabled(process.env.HTTPS)
- ? "__Host-session"
- : "session";
- const sessionId = request.cookies.get(cookieName)?.value;
+ const sessionId = request.cookies.get(getSessionCookieName())?.value;
if (debugProxy) {
console.log(
@@ -106,7 +102,7 @@ export const proxy = async (request: NextRequest) => {
if (!valid) {
const redirectResponse = NextResponse.redirect(loginUrl);
- redirectResponse.cookies.delete(cookieName);
+ redirectResponse.cookies.delete(getSessionCookieName());
if (debugProxy) {
console.log("MIDDLEWARE - session is not ok");
diff --git a/tests/server-actions/auth.test.ts b/tests/server-actions/auth.test.ts
index b1e7e235..ce9a9ff4 100644
--- a/tests/server-actions/auth.test.ts
+++ b/tests/server-actions/auth.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
mockCookies,
mockRedirect,
@@ -31,16 +31,26 @@ vi.mock('@/app/_server/actions/log', () => ({
vi.mock('@/app/_server/actions/users', () => ({
getUsername: vi.fn().mockResolvedValue('testuser'),
+ ensureUser: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/app/_server/actions/auth/ldap', () => ({
+ ldapLogin: vi.fn(),
}))
import { register, login, logout } from '@/app/_server/actions/auth'
import { readJsonFile, writeJsonFile } from '@/app/_server/actions/file'
-import { readSessions, writeSessions } from '@/app/_server/actions/session'
+import { readSessions, writeSessions, createSession } from '@/app/_server/actions/session'
+import { ensureUser } from '@/app/_server/actions/users'
+import { ldapLogin } from '@/app/_server/actions/auth/ldap'
const mockReadJsonFile = readJsonFile as ReturnType
const mockWriteJsonFile = writeJsonFile as ReturnType
const mockReadSessions = readSessions as ReturnType
const mockWriteSessions = writeSessions as ReturnType
+const mockCreateSession = createSession as ReturnType
+const mockEnsureUser = ensureUser as ReturnType
+const mockLdapLogin = ldapLogin as ReturnType
describe('Auth Actions', () => {
beforeEach(() => {
@@ -242,6 +252,145 @@ describe('Auth Actions', () => {
})
})
+ describe('login() with AUTH_MODE=ldap', () => {
+ const ldapUser = {
+ username: 'alice',
+ passwordHash: '',
+ isAdmin: false,
+ failedLoginAttempts: 0,
+ }
+
+ beforeEach(() => {
+ process.env.AUTH_MODE = 'ldap'
+ process.env.DISABLE_BRUTEFORCE_PROTECTION = 'true'
+ mockReadJsonFile.mockResolvedValue([ldapUser])
+ })
+
+ afterEach(() => {
+ delete process.env.AUTH_MODE
+ delete process.env.DISABLE_BRUTEFORCE_PROTECTION
+ })
+
+ it('delegates to ldapLogin instead of checking the local password hash', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: true, username: 'alice', isAdmin: false })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ try { await login(formData) } catch {}
+
+ expect(mockLdapLogin).toHaveBeenCalledWith('alice', 'secret')
+ })
+
+ it('returns an error and does NOT call createSession on invalid_credentials', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: false, kind: 'invalid_credentials' })
+
+ const formData = createFormData({ username: 'alice', password: 'wrong' })
+ const result = await login(formData)
+
+ expect(result).toMatchObject({ error: 'Invalid username or password' })
+ expect(mockCreateSession).not.toHaveBeenCalled()
+ })
+
+ it('increments the brute-force counter on invalid_credentials when user exists', async () => {
+ delete process.env.DISABLE_BRUTEFORCE_PROTECTION
+ mockLdapLogin.mockResolvedValue({ ok: false, kind: 'invalid_credentials' })
+ mockReadJsonFile.mockResolvedValue([{ ...ldapUser, failedLoginAttempts: 0 }])
+
+ const formData = createFormData({ username: 'alice', password: 'wrong' })
+ await login(formData)
+
+ expect(mockWriteJsonFile).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ failedLoginAttempts: 1 }),
+ ]),
+ expect.any(String)
+ )
+ })
+
+ it('returns a generic error and does NOT increment the brute-force counter on connection_error', async () => {
+ delete process.env.DISABLE_BRUTEFORCE_PROTECTION
+ mockLdapLogin.mockResolvedValue({ ok: false, kind: 'connection_error' })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ const result = await login(formData)
+
+ expect(result).toMatchObject({ error: 'Authentication service unavailable' })
+ expect(mockWriteJsonFile).not.toHaveBeenCalled()
+ })
+
+ it('redirects to /auth/login?error=unauthorized on unauthorized result', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: false, kind: 'unauthorized' })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ try {
+ await login(formData)
+ } catch (e: any) {
+ expect(e.message).toContain('REDIRECT:/auth/login?error=unauthorized')
+ }
+ })
+
+ it('calls ensureUser with the username and isAdmin flag on success', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: true, username: 'alice', isAdmin: true })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ try { await login(formData) } catch {}
+
+ expect(mockEnsureUser).toHaveBeenCalledWith('alice', true)
+ })
+
+ it('calls createSession with loginType "ldap" on success', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: true, username: 'alice', isAdmin: false })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ try { await login(formData) } catch {}
+
+ expect(mockCreateSession).toHaveBeenCalledWith(
+ expect.any(String),
+ 'alice',
+ 'ldap'
+ )
+ })
+
+ it('sets the session cookie on success', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: true, username: 'alice', isAdmin: false })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ try { await login(formData) } catch {}
+
+ expect(mockCookies.set).toHaveBeenCalledWith(
+ 'session',
+ expect.any(String),
+ expect.objectContaining({ httpOnly: true, path: '/' })
+ )
+ })
+
+ it('redirects to / on success', async () => {
+ mockLdapLogin.mockResolvedValue({ ok: true, username: 'alice', isAdmin: false })
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ try {
+ await login(formData)
+ } catch (e: any) {
+ expect(e.message).toContain('REDIRECT:/')
+ }
+ })
+
+ it('still applies the brute-force lockout check before contacting LDAP', async () => {
+ delete process.env.DISABLE_BRUTEFORCE_PROTECTION
+ const lockedUser = {
+ ...ldapUser,
+ failedLoginAttempts: 10,
+ nextAllowedLoginAttempt: new Date(Date.now() + 60_000).toISOString(),
+ }
+ mockReadJsonFile.mockResolvedValue([lockedUser])
+
+ const formData = createFormData({ username: 'alice', password: 'secret' })
+ const result = await login(formData)
+
+ expect(result).toMatchObject({ error: 'Too many failed attempts' })
+ expect(mockLdapLogin).not.toHaveBeenCalled()
+ })
+ })
+
describe('logout', () => {
it('should delete session cookie', async () => {
mockCookies.get.mockReturnValue({ value: 'session-id-123' })
diff --git a/tests/server-actions/ldap.test.ts b/tests/server-actions/ldap.test.ts
new file mode 100644
index 00000000..4d90fe89
--- /dev/null
+++ b/tests/server-actions/ldap.test.ts
@@ -0,0 +1,208 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { Client } from "ldapts";
+import { resetAllMocks } from "../setup";
+
+const MockClient = vi.mocked(Client);
+
+const mockGetEnvOrFile = vi.fn();
+
+vi.mock("@/app/_server/actions/file", () => ({
+ getEnvOrFile: (...args: any[]) => mockGetEnvOrFile(...args),
+}));
+
+import { ldapLogin } from "@/app/_server/actions/auth/ldap";
+
+const BASE_ENV = {
+ LDAP_URL: "ldap://ldap.example.com:389",
+ LDAP_BIND_DN: "cn=service,dc=example,dc=com",
+ LDAP_BASE_DN: "ou=users,dc=example,dc=com",
+};
+
+const DEFAULT_ENTRY = {
+ dn: "uid=alice,ou=users,dc=example,dc=com",
+ memberOf: [] as string[],
+};
+
+function setupClient({
+ serviceBindError,
+ searchEntries = [DEFAULT_ENTRY],
+ searchError,
+ userBindError,
+}: {
+ serviceBindError?: Error;
+ searchEntries?: any[];
+ searchError?: Error;
+ userBindError?: Error;
+} = {}) {
+ let bindCalls = 0;
+ const mockBind = vi.fn().mockImplementation(async () => {
+ bindCalls++;
+ if (bindCalls === 1 && serviceBindError) throw serviceBindError;
+ if (bindCalls >= 2 && userBindError) throw userBindError;
+ });
+ const mockSearch = vi.fn().mockImplementation(async () => {
+ if (searchError) throw searchError;
+ return { searchEntries };
+ });
+ const mockUnbind = vi.fn().mockResolvedValue(undefined);
+
+ MockClient.mockImplementation(function () {
+ return { bind: mockBind, search: mockSearch, unbind: mockUnbind };
+ } as any);
+
+ return { mockBind, mockSearch, mockUnbind };
+}
+
+describe("ldapLogin()", () => {
+ beforeEach(() => {
+ resetAllMocks();
+ Object.assign(process.env, BASE_ENV);
+ delete process.env.LDAP_USER_ATTRIBUTE;
+ delete process.env.LDAP_ADMIN_GROUPS;
+ delete process.env.LDAP_USER_GROUPS;
+ mockGetEnvOrFile.mockResolvedValue("service-password");
+ setupClient();
+ });
+
+ afterEach(() => {
+ for (const key of [
+ "LDAP_URL",
+ "LDAP_BIND_DN",
+ "LDAP_BASE_DN",
+ "LDAP_USER_ATTRIBUTE",
+ "LDAP_ADMIN_GROUPS",
+ "LDAP_USER_GROUPS",
+ ]) {
+ delete process.env[key];
+ }
+ });
+
+ it("returns invalid_credentials when user is not found in the directory", async () => {
+ setupClient({ searchEntries: [] });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: false, kind: "invalid_credentials" });
+ });
+
+ it("returns invalid_credentials when the user bind fails (wrong password)", async () => {
+ setupClient({ userBindError: new Error("Invalid credentials") });
+ const result = await ldapLogin("alice", "wrongpassword");
+ expect(result).toEqual({ ok: false, kind: "invalid_credentials" });
+ });
+
+ it("returns { ok: true } on successful bind with no group restrictions configured", async () => {
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: true, username: "alice", isAdmin: false });
+ });
+
+ it("returns connection_error when the service account bind throws a network error", async () => {
+ setupClient({ serviceBindError: new Error("ECONNREFUSED") });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: false, kind: "connection_error" });
+ });
+
+ it("returns connection_error when the search throws (e.g. timeout)", async () => {
+ setupClient({ searchError: new Error("Timeout") });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: false, kind: "connection_error" });
+ });
+
+ it("returns unauthorized when LDAP_USER_GROUPS is set and user has no matching memberOf", async () => {
+ process.env.LDAP_USER_GROUPS = "cn=jotty,ou=groups,dc=example,dc=com";
+ setupClient({
+ searchEntries: [{ dn: DEFAULT_ENTRY.dn, memberOf: [] }],
+ });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: false, kind: "unauthorized" });
+ });
+
+ it("returns { ok: true } when user is in LDAP_USER_GROUPS", async () => {
+ process.env.LDAP_USER_GROUPS = "cn=jotty,ou=groups,dc=example,dc=com";
+ setupClient({
+ searchEntries: [
+ {
+ dn: DEFAULT_ENTRY.dn,
+ memberOf: "cn=jotty,ou=groups,dc=example,dc=com",
+ },
+ ],
+ });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toMatchObject({ ok: true, username: "alice" });
+ });
+
+ it("returns { ok: true } with isAdmin=false when in LDAP_USER_GROUPS but not LDAP_ADMIN_GROUPS", async () => {
+ process.env.LDAP_USER_GROUPS = "cn=jotty,ou=groups,dc=example,dc=com";
+ process.env.LDAP_ADMIN_GROUPS = "cn=admins,ou=groups,dc=example,dc=com";
+ setupClient({
+ searchEntries: [
+ {
+ dn: DEFAULT_ENTRY.dn,
+ memberOf: ["cn=jotty,ou=groups,dc=example,dc=com"],
+ },
+ ],
+ });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: true, username: "alice", isAdmin: false });
+ });
+
+ it("returns { ok: true } with isAdmin=true when user memberOf matches LDAP_ADMIN_GROUPS", async () => {
+ process.env.LDAP_ADMIN_GROUPS = "cn=admins,ou=groups,dc=example,dc=com";
+ setupClient({
+ searchEntries: [
+ {
+ dn: DEFAULT_ENTRY.dn,
+ memberOf: ["cn=admins,ou=groups,dc=example,dc=com"],
+ },
+ ],
+ });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: true, username: "alice", isAdmin: true });
+ });
+
+ it("returns { ok: true } for an admin user even when LDAP_USER_GROUPS is set and they are not in it", async () => {
+ process.env.LDAP_USER_GROUPS = "cn=jotty,ou=groups,dc=example,dc=com";
+ process.env.LDAP_ADMIN_GROUPS = "cn=admins,ou=groups,dc=example,dc=com";
+ setupClient({
+ searchEntries: [
+ {
+ dn: DEFAULT_ENTRY.dn,
+ memberOf: ["cn=admins,ou=groups,dc=example,dc=com"],
+ },
+ ],
+ });
+ const result = await ldapLogin("alice", "password");
+ expect(result).toEqual({ ok: true, username: "alice", isAdmin: true });
+ });
+
+ it("uses uid as the default search attribute when LDAP_USER_ATTRIBUTE is not set", async () => {
+ const { mockSearch } = setupClient();
+ await ldapLogin("alice", "password");
+ expect(mockSearch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ filter: "(uid=alice)" }),
+ );
+ });
+
+ it("uses LDAP_USER_ATTRIBUTE when set (e.g. sAMAccountName)", async () => {
+ process.env.LDAP_USER_ATTRIBUTE = "sAMAccountName";
+ const { mockSearch } = setupClient();
+ await ldapLogin("alice", "password");
+ expect(mockSearch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ filter: "(sAMAccountName=alice)" }),
+ );
+ });
+
+ it("calls unbind after a successful authentication", async () => {
+ const { mockUnbind } = setupClient();
+ await ldapLogin("alice", "password");
+ expect(mockUnbind).toHaveBeenCalledOnce();
+ });
+
+ it("calls unbind even when the user bind fails", async () => {
+ const { mockUnbind } = setupClient({
+ userBindError: new Error("Invalid credentials"),
+ });
+ await ldapLogin("alice", "wrongpassword");
+ expect(mockUnbind).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/server-actions/users.test.ts b/tests/server-actions/users.test.ts
index 78d1ea6f..34eab3d7 100644
--- a/tests/server-actions/users.test.ts
+++ b/tests/server-actions/users.test.ts
@@ -32,6 +32,7 @@ import {
deleteUser,
getUsers,
updateUserSettings,
+ ensureUser,
} from '@/app/_server/actions/users'
describe('Users Actions', () => {
@@ -285,6 +286,83 @@ describe('Users Actions', () => {
})
})
+ describe('ensureUser()', () => {
+ beforeEach(() => {
+ mockFs.mkdir.mockResolvedValue(undefined)
+ mockFs.writeFile.mockResolvedValue(undefined)
+ mockLock.mockResolvedValue(undefined)
+ mockUnlock.mockResolvedValue(undefined)
+ })
+
+ it('creates the first user as both admin and superAdmin', async () => {
+ mockFs.readFile.mockResolvedValue('[]')
+ await ensureUser('alice', false)
+ const written = JSON.parse(mockFs.writeFile.mock.calls[0][1])
+ expect(written).toHaveLength(1)
+ expect(written[0]).toMatchObject({ username: 'alice', isAdmin: true, isSuperAdmin: true })
+ })
+
+ it('creates a subsequent user with isAdmin=false when false is passed', async () => {
+ mockFs.readFile.mockResolvedValue(JSON.stringify([
+ { username: 'existing', passwordHash: '', isAdmin: true, isSuperAdmin: true },
+ ]))
+ await ensureUser('bob', false)
+ const written = JSON.parse(mockFs.writeFile.mock.calls[0][1])
+ const bob = written.find((u: any) => u.username === 'bob')
+ expect(bob).toBeDefined()
+ expect(bob.isAdmin).toBe(false)
+ expect(bob.isSuperAdmin).toBeUndefined()
+ })
+
+ it('creates a subsequent user with isAdmin=true when true is passed', async () => {
+ mockFs.readFile.mockResolvedValue(JSON.stringify([
+ { username: 'existing', passwordHash: '', isAdmin: true, isSuperAdmin: true },
+ ]))
+ await ensureUser('bob', true)
+ const written = JSON.parse(mockFs.writeFile.mock.calls[0][1])
+ const bob = written.find((u: any) => u.username === 'bob')
+ expect(bob.isAdmin).toBe(true)
+ })
+
+ it('promotes an existing non-admin user to admin when isAdmin=true', async () => {
+ mockFs.readFile.mockResolvedValue(JSON.stringify([
+ { username: 'existing', passwordHash: '', isAdmin: true, isSuperAdmin: true },
+ { username: 'alice', passwordHash: '', isAdmin: false },
+ ]))
+ await ensureUser('alice', true)
+ const written = JSON.parse(mockFs.writeFile.mock.calls[0][1])
+ const alice = written.find((u: any) => u.username === 'alice')
+ expect(alice.isAdmin).toBe(true)
+ })
+
+ it('does NOT demote an existing admin user when isAdmin=false', async () => {
+ mockFs.readFile.mockResolvedValue(JSON.stringify([
+ { username: 'alice', passwordHash: '', isAdmin: true },
+ ]))
+ await ensureUser('alice', false)
+ const written = JSON.parse(mockFs.writeFile.mock.calls[0][1])
+ const alice = written.find((u: any) => u.username === 'alice')
+ expect(alice.isAdmin).toBe(true)
+ })
+
+ it('does not create a duplicate entry if called with an existing username', async () => {
+ mockFs.readFile.mockResolvedValue(JSON.stringify([
+ { username: 'alice', passwordHash: '', isAdmin: false },
+ ]))
+ await ensureUser('alice', false)
+ const written = JSON.parse(mockFs.writeFile.mock.calls[0][1])
+ expect(written.filter((u: any) => u.username === 'alice')).toHaveLength(1)
+ })
+
+ it('creates the checklist and notes directories for the user', async () => {
+ mockFs.readFile.mockResolvedValue('[]')
+ await ensureUser('alice', false)
+ const mkdirPaths = mockFs.mkdir.mock.calls.map((c: any[]) => c[0] as string)
+ expect(mkdirPaths.some((p) => p.includes('checklists') && p.includes('alice'))).toBe(true)
+ expect(mkdirPaths.some((p) => p.includes('notes') && p.includes('alice'))).toBe(true)
+ })
+ })
+
describe('updateUserSettings', () => {
it('should return error when not authenticated', async () => {
mockGetSessionId.mockResolvedValue(null)
diff --git a/tests/setup.ts b/tests/setup.ts
index 0b0bc033..dd6fa54b 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -145,6 +145,16 @@ vi.mock("speakeasy", () => ({
},
}));
+vi.mock("ldapts", () => ({
+ Client: vi.fn().mockImplementation(function () {
+ return {
+ bind: vi.fn().mockResolvedValue(undefined),
+ search: vi.fn().mockResolvedValue({ searchEntries: [] }),
+ unbind: vi.fn().mockResolvedValue(undefined),
+ };
+ }),
+}))
+
vi.mock("qrcode", () => ({
default: {
toDataURL: vi.fn().mockResolvedValue("data:image/png;base64,mock"),
diff --git a/yarn.lock b/yarn.lock
index 6a500101..136fc046 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -184,6 +184,11 @@
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
+"@braintree/sanitize-url@^6.0.1":
+ version "6.0.4"
+ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783"
+ integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==
+
"@braintree/sanitize-url@^7.1.1":
version "7.1.1"
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz#15e19737d946559289b915e5dad3b4c28407735e"
@@ -2435,7 +2440,7 @@
resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39"
integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==
-"@types/d3-scale@*", "@types/d3-scale@^4.0.2", "@types/d3-scale@^4.0.8":
+"@types/d3-scale@*", "@types/d3-scale@^4.0.2", "@types/d3-scale@^4.0.3", "@types/d3-scale@^4.0.8":
version "4.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb"
integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==
@@ -2619,6 +2624,13 @@
"@types/linkify-it" "^5"
"@types/mdurl" "^2"
+"@types/mdast@^3.0.0":
+ version "3.0.15"
+ resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5"
+ integrity sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==
+ dependencies:
+ "@types/unist" "^2"
+
"@types/mdast@^4.0.0":
version "4.0.4"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6"
@@ -2722,7 +2734,7 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
-"@types/unist@^2.0.0":
+"@types/unist@^2", "@types/unist@^2.0.0":
version "2.0.11"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4"
integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
@@ -3672,6 +3684,11 @@ cytoscape-fcose@^2.2.0:
dependencies:
cose-base "^2.2.0"
+cytoscape@^3.28.1:
+ version "3.33.2"
+ resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.2.tgz#3a58906b4002b7c237f54dfc9b971983757da791"
+ integrity sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==
+
cytoscape@^3.29.3:
version "3.33.1"
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.1.tgz#449e05d104b760af2912ab76482d24c01cdd4c97"
@@ -3955,7 +3972,7 @@ d3-zoom@3:
d3-selection "2 - 3"
d3-transition "2 - 3"
-d3@^7.9.0:
+d3@^7.4.0, d3@^7.8.2, d3@^7.9.0:
version "7.9.0"
resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d"
integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==
@@ -3991,6 +4008,14 @@ d3@^7.9.0:
d3-transition "3"
d3-zoom "3"
+dagre-d3-es@7.0.10:
+ version "7.0.10"
+ resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc"
+ integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==
+ dependencies:
+ d3 "^7.8.2"
+ lodash-es "^4.17.21"
+
dagre-d3-es@7.0.13:
version "7.0.13"
resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz#acfb4b449f6dcdd48d8ea8081a6d8c59bc8128c3"
@@ -4046,6 +4071,11 @@ dayjs@^1.11.18:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938"
integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==
+dayjs@^1.11.7:
+ version "1.11.20"
+ resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938"
+ integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==
+
debug@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -4144,7 +4174,7 @@ didyoumean@^1.2.2:
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
-diff@^5.2.2:
+diff@^5.0.0, diff@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.2.tgz#0a4742797281d09cfa699b79ea32d27723623bad"
integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==
@@ -4187,6 +4217,11 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
+"dompurify@^3.0.5 <3.1.7":
+ version "3.1.6"
+ resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2"
+ integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==
+
dompurify@^3.2.5, dompurify@^3.3.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.3.1.tgz#c7e1ddebfe3301eacd6c0c12a4af284936dbbb86"
@@ -4239,6 +4274,11 @@ electron-to-chromium@^1.5.263:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7"
integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
+elkjs@^0.9.0:
+ version "0.9.3"
+ resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.9.3.tgz#16711f8ceb09f1b12b99e971b138a8384a529161"
+ integrity sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==
+
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -5229,7 +5269,7 @@ htmlparser2@^8.0.0:
domutils "^3.0.1"
entities "^4.4.0"
-hugeicons-react@^0.3.0:
+hugeicons-react@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/hugeicons-react/-/hugeicons-react-0.3.0.tgz#a788db21b116f16062b75ccf3a3633575722ec9f"
integrity sha512-znmC+uX7xVqcIs0q09LvwEvJkjX0U3xgT05BSiRV19farS4lPONOKjYT0JkcQG5cvfV0rXHSEAEVNjbHAu81rg==
@@ -5768,6 +5808,13 @@ katex@^0.16.22:
dependencies:
commander "^8.3.0"
+katex@^0.16.9:
+ version "0.16.45"
+ resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.45.tgz#ba60d39c54746b6b8d39ce0e7f6eace07143149c"
+ integrity sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==
+ dependencies:
+ commander "^8.3.0"
+
keyv@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -5775,11 +5822,16 @@ keyv@^4.5.4:
dependencies:
json-buffer "3.0.1"
-khroma@^2.1.0:
+khroma@^2.0.0, khroma@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1"
integrity sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==
+kleur@^4.0.3:
+ version "4.1.5"
+ resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
+ integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
+
kolorist@1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c"
@@ -5825,6 +5877,13 @@ lazystream@^1.0.0:
dependencies:
readable-stream "^2.0.5"
+ldapts@^8.1.7:
+ version "8.1.7"
+ resolved "https://registry.yarnpkg.com/ldapts/-/ldapts-8.1.7.tgz#8e358914de1966d631c9a46bd5a69f21c4331f5b"
+ integrity sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==
+ dependencies:
+ strict-event-emitter-types "2.0.0"
+
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
@@ -6030,6 +6089,24 @@ mdast-util-find-and-replace@^3.0.0:
unist-util-is "^6.0.0"
unist-util-visit-parents "^6.0.0"
+mdast-util-from-markdown@^1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0"
+ integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==
+ dependencies:
+ "@types/mdast" "^3.0.0"
+ "@types/unist" "^2.0.0"
+ decode-named-character-reference "^1.0.0"
+ mdast-util-to-string "^3.1.0"
+ micromark "^3.0.0"
+ micromark-util-decode-numeric-character-reference "^1.0.0"
+ micromark-util-decode-string "^1.0.0"
+ micromark-util-normalize-identifier "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+ unist-util-stringify-position "^3.0.0"
+ uvu "^0.5.0"
+
mdast-util-from-markdown@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a"
@@ -6193,6 +6270,13 @@ mdast-util-to-markdown@^2.0.0:
unist-util-visit "^5.0.0"
zwitch "^2.0.0"
+mdast-util-to-string@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789"
+ integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==
+ dependencies:
+ "@types/mdast" "^3.0.0"
+
mdast-util-to-string@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814"
@@ -6210,7 +6294,33 @@ merge2@^1.3.0:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-mermaid@10.9.3, mermaid@^11.12.1:
+mermaid@10.9.3:
+ version "10.9.3"
+ resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.3.tgz#90bc6f15c33dbe5d9507fed31592cc0d88fee9f7"
+ integrity sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==
+ dependencies:
+ "@braintree/sanitize-url" "^6.0.1"
+ "@types/d3-scale" "^4.0.3"
+ "@types/d3-scale-chromatic" "^3.0.0"
+ cytoscape "^3.28.1"
+ cytoscape-cose-bilkent "^4.1.0"
+ d3 "^7.4.0"
+ d3-sankey "^0.12.3"
+ dagre-d3-es "7.0.10"
+ dayjs "^1.11.7"
+ dompurify "^3.0.5 <3.1.7"
+ elkjs "^0.9.0"
+ katex "^0.16.9"
+ khroma "^2.0.0"
+ lodash-es "^4.17.21"
+ mdast-util-from-markdown "^1.3.0"
+ non-layered-tidy-tree-layout "^2.0.2"
+ stylis "^4.1.3"
+ ts-dedent "^2.2.0"
+ uuid "^9.0.0"
+ web-worker "^1.2.0"
+
+mermaid@^11.12.1:
version "11.12.2"
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.12.2.tgz#48bbdb9f724bc2191e2128e1403bf964fff2bc3d"
integrity sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==
@@ -6236,6 +6346,28 @@ mermaid@10.9.3, mermaid@^11.12.1:
ts-dedent "^2.2.0"
uuid "^11.1.0"
+micromark-core-commonmark@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
+ integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==
+ dependencies:
+ decode-named-character-reference "^1.0.0"
+ micromark-factory-destination "^1.0.0"
+ micromark-factory-label "^1.0.0"
+ micromark-factory-space "^1.0.0"
+ micromark-factory-title "^1.0.0"
+ micromark-factory-whitespace "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-chunked "^1.0.0"
+ micromark-util-classify-character "^1.0.0"
+ micromark-util-html-tag-name "^1.0.0"
+ micromark-util-normalize-identifier "^1.0.0"
+ micromark-util-resolve-all "^1.0.0"
+ micromark-util-subtokenize "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.1"
+ uvu "^0.5.0"
+
micromark-core-commonmark@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4"
@@ -6337,6 +6469,15 @@ micromark-extension-gfm@^3.0.0:
micromark-util-combine-extensions "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-factory-destination@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f"
+ integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-factory-destination@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
@@ -6346,6 +6487,16 @@ micromark-factory-destination@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-factory-label@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68"
+ integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+ uvu "^0.5.0"
+
micromark-factory-label@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1"
@@ -6356,6 +6507,14 @@ micromark-factory-label@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-factory-space@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf"
+ integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-factory-space@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc"
@@ -6364,6 +6523,16 @@ micromark-factory-space@^2.0.0:
micromark-util-character "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-factory-title@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1"
+ integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==
+ dependencies:
+ micromark-factory-space "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-factory-title@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94"
@@ -6374,6 +6543,16 @@ micromark-factory-title@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-factory-whitespace@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705"
+ integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==
+ dependencies:
+ micromark-factory-space "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-factory-whitespace@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1"
@@ -6384,6 +6563,14 @@ micromark-factory-whitespace@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-util-character@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc"
+ integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-util-character@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
@@ -6392,6 +6579,13 @@ micromark-util-character@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-util-chunked@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b"
+ integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+
micromark-util-chunked@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051"
@@ -6399,6 +6593,15 @@ micromark-util-chunked@^2.0.0:
dependencies:
micromark-util-symbol "^2.0.0"
+micromark-util-classify-character@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d"
+ integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-util-classify-character@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629"
@@ -6408,6 +6611,14 @@ micromark-util-classify-character@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-util-combine-extensions@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84"
+ integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==
+ dependencies:
+ micromark-util-chunked "^1.0.0"
+ micromark-util-types "^1.0.0"
+
micromark-util-combine-extensions@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9"
@@ -6416,6 +6627,13 @@ micromark-util-combine-extensions@^2.0.0:
micromark-util-chunked "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-util-decode-numeric-character-reference@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6"
+ integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+
micromark-util-decode-numeric-character-reference@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5"
@@ -6423,6 +6641,16 @@ micromark-util-decode-numeric-character-reference@^2.0.0:
dependencies:
micromark-util-symbol "^2.0.0"
+micromark-util-decode-string@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c"
+ integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==
+ dependencies:
+ decode-named-character-reference "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-decode-numeric-character-reference "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+
micromark-util-decode-string@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2"
@@ -6433,16 +6661,33 @@ micromark-util-decode-string@^2.0.0:
micromark-util-decode-numeric-character-reference "^2.0.0"
micromark-util-symbol "^2.0.0"
+micromark-util-encode@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5"
+ integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==
+
micromark-util-encode@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
+micromark-util-html-tag-name@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588"
+ integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==
+
micromark-util-html-tag-name@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825"
integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
+micromark-util-normalize-identifier@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7"
+ integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+
micromark-util-normalize-identifier@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d"
@@ -6450,6 +6695,13 @@ micromark-util-normalize-identifier@^2.0.0:
dependencies:
micromark-util-symbol "^2.0.0"
+micromark-util-resolve-all@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188"
+ integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==
+ dependencies:
+ micromark-util-types "^1.0.0"
+
micromark-util-resolve-all@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b"
@@ -6457,6 +6709,15 @@ micromark-util-resolve-all@^2.0.0:
dependencies:
micromark-util-types "^2.0.0"
+micromark-util-sanitize-uri@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d"
+ integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-encode "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+
micromark-util-sanitize-uri@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
@@ -6466,6 +6727,16 @@ micromark-util-sanitize-uri@^2.0.0:
micromark-util-encode "^2.0.0"
micromark-util-symbol "^2.0.0"
+micromark-util-subtokenize@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1"
+ integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==
+ dependencies:
+ micromark-util-chunked "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+ uvu "^0.5.0"
+
micromark-util-subtokenize@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee"
@@ -6476,16 +6747,49 @@ micromark-util-subtokenize@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
+micromark-util-symbol@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142"
+ integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==
+
micromark-util-symbol@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
+micromark-util-types@^1.0.0, micromark-util-types@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283"
+ integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==
+
micromark-util-types@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e"
integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
+micromark@^3.0.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9"
+ integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==
+ dependencies:
+ "@types/debug" "^4.0.0"
+ debug "^4.0.0"
+ decode-named-character-reference "^1.0.0"
+ micromark-core-commonmark "^1.0.1"
+ micromark-factory-space "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-chunked "^1.0.0"
+ micromark-util-combine-extensions "^1.0.0"
+ micromark-util-decode-numeric-character-reference "^1.0.0"
+ micromark-util-encode "^1.0.0"
+ micromark-util-normalize-identifier "^1.0.0"
+ micromark-util-resolve-all "^1.0.0"
+ micromark-util-sanitize-uri "^1.0.0"
+ micromark-util-subtokenize "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.1"
+ uvu "^0.5.0"
+
micromark@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb"
@@ -6544,6 +6848,11 @@ mlly@^1.7.4, mlly@^1.8.0:
pkg-types "^1.3.1"
ufo "^1.6.1"
+mri@^1.1.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
+ integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
+
ms@^2.1.1, ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -6641,6 +6950,11 @@ node-releases@^2.0.27:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
+non-layered-tidy-tree-layout@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804"
+ integrity sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==
+
nopt@^7.2.1:
version "7.2.1"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
@@ -7680,6 +7994,13 @@ rw@1:
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
+sade@^1.7.3:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
+ integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
+ dependencies:
+ mri "^1.1.0"
+
safe-array-concat@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3"
@@ -7972,6 +8293,11 @@ streamx@^2.15.0:
fast-fifo "^1.3.2"
text-decoder "^1.1.0"
+strict-event-emitter-types@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz#05e15549cb4da1694478a53543e4e2f4abcf277f"
+ integrity sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==
+
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -8141,7 +8467,7 @@ styled-jsx@5.1.6:
dependencies:
client-only "0.0.1"
-stylis@^4.3.6:
+stylis@^4.1.3, stylis@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.6.tgz#7c7b97191cb4f195f03ecab7d52f7902ed378320"
integrity sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==
@@ -8483,6 +8809,13 @@ unist-util-position@^5.0.0:
dependencies:
"@types/unist" "^3.0.0"
+unist-util-stringify-position@^3.0.0:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d"
+ integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==
+ dependencies:
+ "@types/unist" "^2.0.0"
+
unist-util-stringify-position@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2"
@@ -8593,6 +8926,21 @@ uuid@^11.1.0:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912"
integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==
+uuid@^9.0.0:
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
+ integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
+
+uvu@^0.5.0:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"
+ integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==
+ dependencies:
+ dequal "^2.0.0"
+ diff "^5.0.0"
+ kleur "^4.0.3"
+ sade "^1.7.3"
+
vfile-location@^5.0.0:
version "5.0.3"
resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-5.0.3.tgz#cb9eacd20f2b6426d19451e0eafa3d0a846225c3"
@@ -8722,6 +9070,11 @@ web-namespaces@^2.0.0:
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
+web-worker@^1.2.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5"
+ integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==
+
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"