diff --git a/app/(loggedOutRoutes)/auth/login/page.tsx b/app/(loggedOutRoutes)/auth/login/page.tsx index 1e898f19..059fcd28 100644 --- a/app/(loggedOutRoutes)/auth/login/page.tsx +++ b/app/(loggedOutRoutes)/auth/login/page.tsx @@ -4,28 +4,31 @@ import LoginForm from "@/app/_components/GlobalComponents/Auth/LoginForm"; import { AuthShell } from "@/app/_components/GlobalComponents/Auth/AuthShell"; import { getTranslations } from "next-intl/server"; import { SsoOnlyLogin } from "@/app/_components/GlobalComponents/Auth/SsoOnlyLogin"; -import { isEnvEnabled } from "@/app/_utils/env-utils"; +import { isEnvEnabled, getAuthMode } from "@/app/_utils/env-utils"; export const dynamic = "force-dynamic"; export default async function LoginPage() { const t = await getTranslations("auth"); - const ssoEnabled = process.env.SSO_MODE === "oidc"; + const authMode = getAuthMode(); + const ssoIsOidc = authMode === "oidc"; const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL); const hasExistingUsers = await hasUsers(); - if ((!hasExistingUsers && !ssoEnabled) || (!hasExistingUsers && allowLocal)) { + if (!hasExistingUsers && !authMode) { redirect("/auth/setup"); } - if (ssoEnabled && !allowLocal) { + if (ssoIsOidc && !allowLocal) { return ; } + const showRegisterLink = allowLocal && !hasExistingUsers; + return (
- +
); diff --git a/app/(loggedOutRoutes)/auth/setup/page.tsx b/app/(loggedOutRoutes)/auth/setup/page.tsx index 7399b7e2..0fb0935f 100644 --- a/app/(loggedOutRoutes)/auth/setup/page.tsx +++ b/app/(loggedOutRoutes)/auth/setup/page.tsx @@ -2,15 +2,14 @@ import { redirect } from "next/navigation"; import { hasUsers } from "@/app/_server/actions/users"; import SetupForm from "@/app/(loggedOutRoutes)/auth/setup/setup-form"; import { AuthShell } from "@/app/_components/GlobalComponents/Auth/AuthShell"; -import { isEnvEnabled } from "@/app/_utils/env-utils"; +import { isEnvEnabled, getAuthMode } from "@/app/_utils/env-utils"; export const dynamic = "force-dynamic"; export default async function SetupPage() { - const ssoEnabled = process.env.SSO_MODE === "oidc"; const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL); - if (ssoEnabled && !allowLocal) { + if (getAuthMode() && !allowLocal) { redirect("/auth/login"); } diff --git a/app/_components/GlobalComponents/Auth/LoginForm.tsx b/app/_components/GlobalComponents/Auth/LoginForm.tsx index 862b17f3..392d60a8 100644 --- a/app/_components/GlobalComponents/Auth/LoginForm.tsx +++ b/app/_components/GlobalComponents/Auth/LoginForm.tsx @@ -10,7 +10,13 @@ import { Orbit01Icon } from "hugeicons-react"; import { Logo } from "@/app/_components/GlobalComponents/Layout/Logo/Logo"; import { useTranslations } from "next-intl"; -export default function LoginForm({ ssoEnabled }: { ssoEnabled: boolean }) { +export default function LoginForm({ + ssoEnabled, + showRegisterLink = false, +}: { + ssoEnabled: boolean; + showRegisterLink?: boolean; +}) { const t = useTranslations('auth'); const searchParams = useSearchParams(); const [error, setError] = useState(""); @@ -132,7 +138,7 @@ export default function LoginForm({ ssoEnabled }: { ssoEnabled: boolean }) { )} -
+ {error && (
{error} @@ -200,6 +206,14 @@ export default function LoginForm({ ssoEnabled }: { ssoEnabled: boolean }) { + {showRegisterLink && ( +
+ + {t('createAccount')} + +
+ )} + {appVersion && (
{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"