import { env } from "cloudflare:workers"; import { all, first, run } from "./db"; import { forbidden, notFoundError } from "./errors"; import { disabledMessage } from "~/lib/account-disable"; import type { DisableReason, DisabledNotice } from "~/lib/account-disable"; import { userChanged } from "./user-row"; export type AccountDisableState = { reason: string; at: number; note: string | null }; export type DisabledAccount = { id: string; name: string; kind: string; reason: string; note: string | null; disabledAt: number; }; /** SUPPORT_EMAIL, else the legal contact, so a forge without a dedicated inbox still shows a way back in. */ export function supportEmail(): string | null { return env.SUPPORT_EMAIL?.trim() || env.LEGAL_CONTACT_EMAIL?.trim() || null; } export function disabledNotice(reason: string): DisabledNotice { const email = supportEmail(); return { reason, message: disabledMessage(reason, email), supportEmail: email }; } export async function accountDisableState(accountId: string): Promise { const row = await first<{ disabled_at: number | null; disabled_reason: string | null; disabled_note: string | null }>( "SELECT disabled_at, disabled_reason, disabled_note FROM accounts WHERE id = ?", accountId, ); if (!row || row.disabled_at === null) return null; return { reason: row.disabled_reason ?? "admin", at: row.disabled_at, note: row.disabled_note }; } /** Refuses sign-in (and anything else that would hand out a credential) for a disabled account. */ export async function assertAccountActive(accountId: string): Promise { const state = await accountDisableState(accountId); if (state) throw forbidden(disabledNotice(state.reason).message); } /** Marks the account disabled; true only on the transition, so retried triggers don't email twice. */ export async function markAccountDisabled( accountId: string, reason: DisableReason, note: string | null, now = Date.now(), ): Promise { const account = await first<{ id: string }>("SELECT id FROM accounts WHERE id = ?", accountId); if (!account) throw notFoundError("No such account"); const result = await run( "UPDATE accounts SET disabled_at = ?, disabled_reason = ?, disabled_note = ? WHERE id = ? AND disabled_at IS NULL", now, reason, note, accountId, ); const fresh = result.meta.changes > 0; // Existing sessions die even after a later re-enable: the account holder signs in again. if (fresh) await run("UPDATE users SET sessions_revoked_at = ? WHERE account_id = ?", now, accountId); await userChanged(accountId); return fresh; } export async function clearAccountDisabled(accountId: string): Promise { const result = await run( "UPDATE accounts SET disabled_at = NULL, disabled_reason = NULL, disabled_note = NULL WHERE id = ? AND disabled_at IS NOT NULL", accountId, ); await userChanged(accountId); return result.meta.changes > 0; } export async function listDisabledAccounts(): Promise { const rows = await all<{ id: string; name: string; kind: string; disabled_reason: string | null; disabled_note: string | null; disabled_at: number; }>( `SELECT id, name, kind, disabled_reason, disabled_note, disabled_at FROM accounts WHERE disabled_at IS NOT NULL ORDER BY disabled_at DESC LIMIT 200`, ); return rows.map((row) => ({ id: row.id, name: row.name, kind: row.kind, reason: row.disabled_reason ?? "admin", note: row.disabled_note, disabledAt: row.disabled_at, })); } /** Disabled orgs the user belongs to, for the member banner. */ export async function disabledOrgsOf(userId: string): Promise<{ id: string; name: string; reason: string }[]> { const rows = await all<{ id: string; name: string; disabled_reason: string | null }>( `SELECT a.id, a.name, a.disabled_reason FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? AND a.disabled_at IS NOT NULL LIMIT 10`, userId, ); return rows.map((row) => ({ id: row.id, name: row.name, reason: row.disabled_reason ?? "admin" })); }