@valentinkolb/cloud 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/scripts/build.ts CHANGED
@@ -21,11 +21,13 @@
21
21
  * it ships a `scripts/build-extras.ts` that this script runs at the end.
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
- import { cp, mkdir, readdir, rm } from "node:fs/promises";
25
- import { dirname, resolve } from "node:path";
24
+ import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
25
+ import { dirname, extname, resolve } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
+ import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
27
28
  import tailwind from "bun-plugin-tailwind";
28
29
  import { Glob, CryptoHasher } from "bun";
30
+ import { promisify } from "node:util";
29
31
 
30
32
  const appId = process.env.APP_ID;
31
33
  if (!appId) throw new Error("APP_ID env var required");
@@ -47,6 +49,8 @@ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DI
47
49
 
48
50
  const dist = resolve(root, "dist");
49
51
  const distPublic = resolve(dist, "public");
52
+ const compressBrotli = promisify(brotliCompress);
53
+ const compressGzip = promisify(gzip);
50
54
 
51
55
  await rm(dist, { recursive: true, force: true });
52
56
  await mkdir(distPublic, { recursive: true });
@@ -59,6 +63,29 @@ const islandId = (file: string): string => {
59
63
  return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
60
64
  };
61
65
 
66
+ const compressibleExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"]);
67
+
68
+ async function precompressDistAssets(dir: string): Promise<void> {
69
+ if (!existsSync(dir)) return;
70
+
71
+ for await (const file of new Glob("**/*").scan({ cwd: dir, absolute: true, onlyFiles: true })) {
72
+ if (file.endsWith(".br") || file.endsWith(".gz")) continue;
73
+ if (!compressibleExtensions.has(extname(file))) continue;
74
+
75
+ const source = await readFile(file);
76
+ const [br, gz] = await Promise.all([
77
+ compressBrotli(source, {
78
+ params: {
79
+ [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
80
+ },
81
+ }),
82
+ compressGzip(source, { level: zlibConstants.Z_BEST_COMPRESSION }),
83
+ ]);
84
+
85
+ await Promise.all([writeFile(`${file}.br`, br), writeFile(`${file}.gz`, gz)]);
86
+ }
87
+ }
88
+
62
89
  // Register the app's SSR plugin (Solid JSX transform + island bundler).
63
90
  // In the monorepo this resolves via `packages/<id>/src/config`; in standalone
64
91
  // it resolves via the appDir path (because the script's relative imports
@@ -133,4 +160,7 @@ if (existsSync(extras)) {
133
160
  await import(extras);
134
161
  }
135
162
 
163
+ await precompressDistAssets(resolve(dist, "public"));
164
+ await precompressDistAssets(resolve(dist, "_ssr"));
165
+
136
166
  console.log(`Built ${appId} → ${dist}`);
@@ -9,7 +9,6 @@ import type { SsrConfig } from "@valentinkolb/ssr";
9
9
  import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
10
10
  import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
11
11
  import { Hono } from "hono";
12
- import { serveStatic } from "hono/bun";
13
12
  import { generateSpecs } from "hono-openapi";
14
13
  import type { AppCapabilities, AppLifecycle, AppMeta, AppSearchContext, CloudContext } from "../contracts/app";
15
14
  import type { AppRegistryEntry } from "../contracts/registry";
@@ -23,6 +22,7 @@ import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
23
22
  import { registerSettings, type SettingDef } from "../services/settings/defaults";
24
23
  import { createHeartbeat } from "./heartbeat";
25
24
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
25
+ import { servePublicAsset } from "./static-assets";
26
26
 
27
27
  /** Cache-busting version stamp — changes on every server start / rebuild. */
28
28
  const v = Date.now();
@@ -252,6 +252,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
252
252
  <meta name="mobile-web-app-capable" content="yes">
253
253
  <link rel="icon" href="/branding/favicon">
254
254
  <style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
255
+ <link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
256
+ <link rel="stylesheet" href="/public/fonts.css?v=${v}">
257
+ <link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
255
258
  <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
256
259
  <link rel="stylesheet" href="/public/global.css?v=${v}">
257
260
  <script>${themeBootstrapScript}</script>
@@ -349,19 +352,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
349
352
 
350
353
  const server = new Hono()
351
354
  .route(ssrMountPath, routes(config))
352
- .use(
353
- "/public/*",
354
- serveStatic({
355
- root: "./",
356
- onFound: (_path, c) => {
357
- c.header("Cache-Control", isDevelopment ? "no-store" : "public, max-age=31536000, immutable");
358
- },
359
- }),
360
- )
361
- // serveStatic calls next() on miss — terminate /public/* here so a
362
- // missing asset is a clean 404 instead of falling through to the app
363
- // fetch (which might render an HTML page for the missing path).
364
- .all("/public/*", (c) => c.notFound());
355
+ .all("/public/*", servePublicAsset(isDevelopment));
365
356
 
366
357
  if (startOpts.capabilities?.search) {
367
358
  const searchRun = startOpts.capabilities.search.run;
@@ -0,0 +1,86 @@
1
+ import { resolve, sep } from "node:path";
2
+ import type { Context } from "hono";
3
+
4
+ const publicRoot = resolve(process.cwd(), "public");
5
+
6
+ function decodePathname(pathname: string): string | null {
7
+ try {
8
+ return decodeURIComponent(pathname);
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function resolvePublicAsset(pathname: string): string | null {
15
+ const decoded = decodePathname(pathname);
16
+ if (!decoded?.startsWith("/public/")) return null;
17
+
18
+ const path = resolve(process.cwd(), decoded.slice(1));
19
+ if (path !== publicRoot && !path.startsWith(`${publicRoot}${sep}`)) return null;
20
+ return path;
21
+ }
22
+
23
+ function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolean {
24
+ if (!header) return false;
25
+
26
+ return header.split(",").some((part) => {
27
+ const [name, ...params] = part.trim().split(";");
28
+ if (name?.trim().toLowerCase() !== encoding) return false;
29
+ return !params.some((param) => {
30
+ const [key, value] = param.trim().split("=");
31
+ return key?.toLowerCase() === "q" && Number(value) === 0;
32
+ });
33
+ });
34
+ }
35
+
36
+ async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
37
+ const suffix = encoding === "br" ? ".br" : ".gz";
38
+ const file = Bun.file(`${path}${suffix}`);
39
+ return (await file.exists()) ? file : null;
40
+ }
41
+
42
+ export function servePublicAsset(isDevelopment: boolean) {
43
+ return async (c: Context) => {
44
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
45
+ c.header("Allow", "GET, HEAD");
46
+ return c.text("Method Not Allowed", 405);
47
+ }
48
+
49
+ const path = resolvePublicAsset(new URL(c.req.url).pathname);
50
+ if (!path) return c.notFound();
51
+
52
+ const sourceFile = Bun.file(path);
53
+ if (!(await sourceFile.exists())) return c.notFound();
54
+
55
+ const acceptEncoding = c.req.header("Accept-Encoding") ?? null;
56
+ let selected = sourceFile;
57
+ let selectedEncoding: "br" | "gzip" | null = null;
58
+
59
+ if (acceptsEncoding(acceptEncoding, "br")) {
60
+ const br = await encodedFile(path, "br");
61
+ if (br) {
62
+ selected = br;
63
+ selectedEncoding = "br";
64
+ }
65
+ }
66
+
67
+ if (!selectedEncoding && acceptsEncoding(acceptEncoding, "gzip")) {
68
+ const gz = await encodedFile(path, "gzip");
69
+ if (gz) {
70
+ selected = gz;
71
+ selectedEncoding = "gzip";
72
+ }
73
+ }
74
+
75
+ const headers = new Headers({
76
+ "Cache-Control": isDevelopment ? "no-store" : "public, max-age=31536000, immutable",
77
+ "Content-Length": String(selected.size),
78
+ "Content-Type": sourceFile.type || "application/octet-stream",
79
+ Vary: "Accept-Encoding",
80
+ });
81
+
82
+ if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
83
+
84
+ return new Response(c.req.method === "HEAD" ? null : selected, { headers });
85
+ };
86
+ }
package/src/api/auth.ts CHANGED
@@ -49,8 +49,8 @@ const app = new Hono<AuthContext>()
49
49
 
50
50
  const loginResult = await authFlows.ipa.login({ username, password });
51
51
  if (!loginResult.ok && loginResult.reason === "password_expired") {
52
- log.info("Login failed", { uid: username, reason: "password_expired" });
53
- return c.json({ message: "Password expired", passwordExpired: true }, 401);
52
+ log.info("Login failed", { uid: loginResult.uid, reason: "password_expired" });
53
+ return c.json({ message: "Password expired", passwordExpired: true, ipaUid: loginResult.uid }, 401);
54
54
  }
55
55
  if (!loginResult.ok) {
56
56
  log.info("Login failed", {
@@ -63,7 +63,7 @@ const app = new Hono<AuthContext>()
63
63
  // Store minimal session in Redis
64
64
  const sessionToken = await auth.session.create(c, loginResult.userId);
65
65
 
66
- log.info("Login successful", { uid: username });
66
+ log.info("Login successful", { uid: loginResult.user.uid });
67
67
  return c.json({
68
68
  session_token: sessionToken,
69
69
  user: loginResult.user,
@@ -1,10 +1,11 @@
1
1
  import { sql } from "bun";
2
2
  import { accounts } from "../accounts";
3
+ import { logger } from "../logging";
3
4
  import { providers } from "../providers";
4
5
  import type { User } from "../../contracts/shared";
5
6
 
6
7
  type IpaLoginFailure =
7
- | { ok: false; status: 401; reason: "password_expired"; message: string }
8
+ | { ok: false; status: 401; reason: "password_expired"; message: string; uid: string }
8
9
  | { ok: false; status: 401; reason: "invalid_credentials"; message: string }
9
10
  | { ok: false; status: 400; reason: "user_not_synced"; message: string }
10
11
  | { ok: false; status: 400; reason: "user_not_found"; message: string }
@@ -20,6 +21,42 @@ type IpaLoginSuccess = {
20
21
 
21
22
  export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
22
23
 
24
+ const log = logger("auth:ipa");
25
+ const DUMMY_LOGIN_UID = "__cloud_invalid_ipa_email_login__";
26
+
27
+ const normalizeEmail = (value: string): string => value.trim().toLowerCase();
28
+
29
+ const resolveIpaLoginUid = async (identifier: string): Promise<string | null> => {
30
+ const trimmed = identifier.trim();
31
+ if (!trimmed) return null;
32
+ if (!trimmed.includes("@")) return trimmed;
33
+
34
+ const rows = await sql<{ uid: string }[]>`
35
+ SELECT uid
36
+ FROM auth.users
37
+ WHERE provider = 'ipa'
38
+ AND lower(btrim(mail)) = ${normalizeEmail(trimmed)}
39
+ `;
40
+
41
+ if (rows.length !== 1) {
42
+ if (rows.length > 1) {
43
+ log.warn("FreeIPA email login skipped: ambiguous email", {
44
+ email: normalizeEmail(trimmed),
45
+ matches: rows.length,
46
+ });
47
+ }
48
+ return null;
49
+ }
50
+ return rows[0]!.uid;
51
+ };
52
+
53
+ const failInvalidCredentials = async (params: { identifier: string; password: string }): Promise<IpaLoginFailure> => {
54
+ if (params.identifier.trim().includes("@")) {
55
+ await providers.ipa.auth.login(DUMMY_LOGIN_UID, params.password).catch(() => undefined);
56
+ }
57
+ return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
58
+ };
59
+
23
60
  const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: string; user: User } | IpaLoginFailure> => {
24
61
  const userRows = await sql`
25
62
  SELECT id FROM auth.users
@@ -50,9 +87,14 @@ const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: strin
50
87
  };
51
88
 
52
89
  export const login = async (params: { username: string; password: string }): Promise<IpaLoginFlowResult> => {
53
- const loginResult = await providers.ipa.auth.login(params.username, params.password);
90
+ const uid = await resolveIpaLoginUid(params.username);
91
+ if (!uid) {
92
+ return failInvalidCredentials({ identifier: params.username, password: params.password });
93
+ }
94
+
95
+ const loginResult = await providers.ipa.auth.login(uid, params.password);
54
96
  if (loginResult.status === "password_expired") {
55
- return { ok: false, status: 401, reason: "password_expired", message: "Password expired" };
97
+ return { ok: false, status: 401, reason: "password_expired", message: "Password expired", uid };
56
98
  }
57
99
  if (loginResult.status !== "success") {
58
100
  return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
@@ -61,7 +103,7 @@ export const login = async (params: { username: string; password: string }): Pro
61
103
  // Must reach a "synced" outcome before granting a session. Stale mirror rows
62
104
  // (expired remotely, dropped from sync scope, or fetch failures) must never
63
105
  // grant a fresh local session on the back of successful FreeIPA credentials.
64
- const syncOutcome = await providers.ipa.sync.user(params.username);
106
+ const syncOutcome = await providers.ipa.sync.user(uid);
65
107
  switch (syncOutcome.status) {
66
108
  case "synced":
67
109
  break;
@@ -97,7 +139,7 @@ export const login = async (params: { username: string; password: string }): Pro
97
139
  };
98
140
  }
99
141
 
100
- const userResult = await loadSyncedIpaUser(params.username);
142
+ const userResult = await loadSyncedIpaUser(uid);
101
143
  if (!userResult.ok) return userResult;
102
144
 
103
145
  return {
@@ -1,4 +1,4 @@
1
- import { sql } from "bun";
1
+ import { redis, sql } from "bun";
2
2
  import { accounts } from "../accounts";
3
3
  import { notifications } from "../notifications";
4
4
  import { providers } from "../providers";
@@ -6,33 +6,95 @@ import * as settings from "../settings";
6
6
  import { renderTemplate } from "../settings/templates";
7
7
  import type { User } from "../../contracts/shared";
8
8
  import { createAuthLoginUrl } from "../../shared/redirect";
9
+ import { logger } from "../logging";
10
+
11
+ const log = logger("auth:magic-link");
12
+ const IPA_HINT_COOLDOWN_SECONDS = 300;
13
+
14
+ const normalizeEmail = (email: string): string => email.trim().toLowerCase();
15
+ const ipaHintCooldownKey = (email: string): string => `ipa-email-login-hint-cooldown:${email}`;
16
+
17
+ const getAppUrl = async (): Promise<string> => {
18
+ const rawAppUrl = await settings.get<string>("app.url");
19
+ return rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
20
+ };
21
+
22
+ const hasIpaAccountForEmail = async (email: string): Promise<boolean> => {
23
+ const rows = await sql<{ exists: boolean }[]>`
24
+ SELECT EXISTS (
25
+ SELECT 1
26
+ FROM auth.users
27
+ WHERE provider = 'ipa'
28
+ AND lower(btrim(mail)) = ${email}
29
+ ) AS exists
30
+ `;
31
+ return Boolean(rows[0]?.exists);
32
+ };
33
+
34
+ const claimIpaHintCooldown = async (email: string): Promise<boolean> => {
35
+ const result = await redis.send("SET", [
36
+ ipaHintCooldownKey(email),
37
+ "1",
38
+ "EX",
39
+ String(IPA_HINT_COOLDOWN_SECONDS),
40
+ "NX",
41
+ ]);
42
+ return result === "OK";
43
+ };
44
+
45
+ const sendIpaEmailLoginHint = async (params: { email: string; redirectTo?: string }): Promise<void> => {
46
+ const appUrl = await getAppUrl();
47
+ const loginUrl = createAuthLoginUrl(appUrl, {
48
+ method: "ipa",
49
+ redirectTo: params.redirectTo,
50
+ });
51
+ const [appName, contactEmail, template] = await Promise.all([
52
+ settings.get<string>("app.name"),
53
+ settings.get<string>("app.contact_email"),
54
+ settings.get<string>("mail.ipa_email_login_hint"),
55
+ ]);
56
+
57
+ await notifications.send({
58
+ type: "email",
59
+ recipient: params.email,
60
+ subject: `${appName} FreeIPA Sign In`,
61
+ rawHtml: renderTemplate(template, {
62
+ EMAIL: params.email,
63
+ LOGIN_URL: loginUrl,
64
+ APP_NAME: appName,
65
+ CONTACT_EMAIL: contactEmail?.trim() ?? "",
66
+ }),
67
+ });
68
+ };
9
69
 
10
70
  export const request = async (params: { email: string; redirectTo?: string }): Promise<
11
71
  | { ok: true }
12
72
  | { ok: false; status: 400; message: string }
13
73
  > => {
14
- const userRows = await sql`SELECT uid, provider FROM auth.users WHERE mail = ${params.email}`;
74
+ const email = normalizeEmail(params.email);
75
+ const hasIpaUser = await hasIpaAccountForEmail(email);
76
+ const userRows = hasIpaUser ? [] : await sql`SELECT uid, provider FROM auth.users WHERE lower(btrim(mail)) = ${email}`;
15
77
  const hasLocalUser = userRows.some((row: { provider: string | null }) => row.provider === "local");
16
- const hasIpaUser = userRows.some((row: { provider: string | null }) => row.provider === "ipa");
17
78
  const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
18
79
 
19
- if (!hasLocalUser && !allowSelfRegistration) {
20
- return {
21
- ok: false,
22
- status: 400,
23
- message: "Only existing local accounts can sign in with email. Contact an administrator if you need access.",
24
- };
80
+ if (hasIpaUser) {
81
+ if (await claimIpaHintCooldown(email)) {
82
+ void sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo }).catch((error) => {
83
+ log.warn("Failed to send FreeIPA email-login hint", {
84
+ email,
85
+ error: error instanceof Error ? error.message : String(error),
86
+ });
87
+ });
88
+ }
89
+ return { ok: true };
25
90
  }
26
91
 
27
- if (!hasLocalUser && hasIpaUser) {
28
- // Return ok without sending email to prevent account enumeration.
29
- // IPA-only users must authenticate via Kerberos, not magic-link.
92
+ if (!hasLocalUser && !allowSelfRegistration) {
30
93
  return { ok: true };
31
94
  }
32
95
 
33
- const token = await providers.local.auth.createMagicLinkToken({ email: params.email, ttlSeconds: 300 });
34
- const rawAppUrl = await settings.get<string>("app.url");
35
- const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
96
+ const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
97
+ const appUrl = await getAppUrl();
36
98
  const magicLink = createAuthLoginUrl(appUrl, { token, redirectTo: params.redirectTo });
37
99
 
38
100
  const appName = await settings.get<string>("app.name");
@@ -40,7 +102,7 @@ export const request = async (params: { email: string; redirectTo?: string }): P
40
102
 
41
103
  await notifications.send({
42
104
  type: "email",
43
- recipient: params.email,
105
+ recipient: email,
44
106
  subject: `${appName} Login Code`,
45
107
  rawHtml: renderTemplate(template, {
46
108
  TOKEN: token,
@@ -63,13 +125,22 @@ export const verify = async (params: { token: string }): Promise<
63
125
  }
64
126
 
65
127
  const { email } = payload;
128
+ const normalizedEmail = normalizeEmail(email);
129
+ if (await hasIpaAccountForEmail(normalizedEmail)) {
130
+ return {
131
+ ok: false,
132
+ status: 401,
133
+ message: "This email address belongs to a FreeIPA-managed account. Sign in with FreeIPA.",
134
+ };
135
+ }
136
+
66
137
  // Reject expired accounts at login time, not just during cleanup. Without
67
138
  // this, an expired local user / guest could still authenticate in the
68
139
  // window between expiry and the next lifecycle run.
69
140
  const userRows = await sql`
70
141
  SELECT id, account_expires
71
142
  FROM auth.users
72
- WHERE mail = ${email} AND provider = 'local'
143
+ WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
73
144
  AND (account_expires IS NULL OR account_expires > now())
74
145
  ORDER BY profile = 'user' DESC
75
146
  LIMIT 1
@@ -84,7 +155,7 @@ export const verify = async (params: { token: string }): Promise<
84
155
  const expiredRows = await sql`
85
156
  SELECT id
86
157
  FROM auth.users
87
- WHERE mail = ${email} AND provider = 'local'
158
+ WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
88
159
  AND account_expires IS NOT NULL AND account_expires <= now()
89
160
  LIMIT 1
90
161
  `;
@@ -1,13 +1,14 @@
1
1
  import { sql } from "bun";
2
- import { sendEmail } from "./email";
3
2
  import type { PaginationParams } from "../../contracts/shared";
4
- import { escapeLikePattern } from "../postgres";
5
3
  import { logger } from "../logging";
4
+ import { escapeLikePattern } from "../postgres";
5
+ import { sendEmail } from "./email";
6
6
 
7
7
  const log = logger("notifications");
8
8
 
9
9
  export type NotificationType = "email";
10
10
  export type NotificationStatus = "sent" | "pending" | "error";
11
+ export type NotificationStatusSummary = Record<NotificationStatus, number>;
11
12
 
12
13
  /**
13
14
  * Computes notification delivery status from sent/error timestamps.
@@ -56,6 +57,12 @@ export type NotificationMessage = {
56
57
  status: NotificationStatus;
57
58
  };
58
59
 
60
+ const emptyStatusSummary = (): NotificationStatusSummary => ({
61
+ sent: 0,
62
+ pending: 0,
63
+ error: 0,
64
+ });
65
+
59
66
  type DbNotificationRow = {
60
67
  id: string;
61
68
  type: NotificationType;
@@ -143,23 +150,26 @@ export const sendToUser = async (params: SendToUserParams): Promise<{ ok: true;
143
150
  */
144
151
  export const list = async (
145
152
  pagination: PaginationParams,
146
- options?: { sentBy?: string; isAdmin?: boolean; search?: string },
153
+ options?: { sentBy?: string; isAdmin?: boolean; search?: string; status?: NotificationStatus },
147
154
  ): Promise<{ notifications: NotificationMessage[]; total: number }> => {
148
155
  const { offset, perPage } = pagination;
149
- const { sentBy, isAdmin, search } = options ?? {};
156
+ const { sentBy, isAdmin, search, status } = options ?? {};
150
157
 
151
158
  // Build query based on permissions
152
159
  let countRows: Array<{ count: number | string }> = [];
153
160
  let dataRows: DbNotificationRow[] = [];
154
161
 
155
162
  const searchPattern = search ? `%${escapeLikePattern(search)}%` : null;
163
+ const statusFilter = status ?? null;
156
164
 
157
165
  if (isAdmin) {
158
166
  // Admins see all notifications
159
167
  if (searchPattern) {
160
168
  countRows = await sql`
161
169
  SELECT COUNT(*)::int as count FROM notifications.messages
162
- WHERE subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\'
170
+ WHERE
171
+ (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
172
+ AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
163
173
  `;
164
174
  dataRows = await sql`
165
175
  SELECT
@@ -168,12 +178,17 @@ export const list = async (
168
178
  u.display_name as sent_by_name
169
179
  FROM notifications.messages m
170
180
  LEFT JOIN auth.users u ON m.sent_by = u.id
171
- WHERE m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\'
181
+ WHERE
182
+ (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
183
+ AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
172
184
  ORDER BY m.created_at DESC
173
185
  LIMIT ${perPage} OFFSET ${offset}
174
186
  `;
175
187
  } else {
176
- countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages`;
188
+ countRows = await sql`
189
+ SELECT COUNT(*)::int as count FROM notifications.messages
190
+ WHERE ${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
191
+ `;
177
192
  dataRows = await sql`
178
193
  SELECT
179
194
  m.id, m.type, m.recipient, m.subject, m.content,
@@ -181,6 +196,7 @@ export const list = async (
181
196
  u.display_name as sent_by_name
182
197
  FROM notifications.messages m
183
198
  LEFT JOIN auth.users u ON m.sent_by = u.id
199
+ WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
184
200
  ORDER BY m.created_at DESC
185
201
  LIMIT ${perPage} OFFSET ${offset}
186
202
  `;
@@ -190,7 +206,10 @@ export const list = async (
190
206
  if (searchPattern) {
191
207
  countRows = await sql`
192
208
  SELECT COUNT(*)::int as count FROM notifications.messages
193
- WHERE sent_by = ${sentBy} AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
209
+ WHERE
210
+ sent_by = ${sentBy}
211
+ AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
212
+ AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
194
213
  `;
195
214
  dataRows = await sql`
196
215
  SELECT
@@ -199,12 +218,20 @@ export const list = async (
199
218
  u.display_name as sent_by_name
200
219
  FROM notifications.messages m
201
220
  LEFT JOIN auth.users u ON m.sent_by = u.id
202
- WHERE m.sent_by = ${sentBy} AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
221
+ WHERE
222
+ m.sent_by = ${sentBy}
223
+ AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
224
+ AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
203
225
  ORDER BY m.created_at DESC
204
226
  LIMIT ${perPage} OFFSET ${offset}
205
227
  `;
206
228
  } else {
207
- countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages WHERE sent_by = ${sentBy}`;
229
+ countRows = await sql`
230
+ SELECT COUNT(*)::int as count FROM notifications.messages
231
+ WHERE
232
+ sent_by = ${sentBy}
233
+ AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
234
+ `;
208
235
  dataRows = await sql`
209
236
  SELECT
210
237
  m.id, m.type, m.recipient, m.subject, m.content,
@@ -212,7 +239,9 @@ export const list = async (
212
239
  u.display_name as sent_by_name
213
240
  FROM notifications.messages m
214
241
  LEFT JOIN auth.users u ON m.sent_by = u.id
215
- WHERE m.sent_by = ${sentBy}
242
+ WHERE
243
+ m.sent_by = ${sentBy}
244
+ AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
216
245
  ORDER BY m.created_at DESC
217
246
  LIMIT ${perPage} OFFSET ${offset}
218
247
  `;
@@ -246,6 +275,47 @@ export const list = async (
246
275
  return { notifications, total };
247
276
  };
248
277
 
278
+ /**
279
+ * Count current notification statuses for recent entries.
280
+ */
281
+ export const getStatusSummary = async (options?: {
282
+ sentBy?: string;
283
+ isAdmin?: boolean;
284
+ days?: number;
285
+ }): Promise<NotificationStatusSummary> => {
286
+ const { sentBy, isAdmin, days = 7 } = options ?? {};
287
+ const windowDays = Math.max(1, Math.floor(days));
288
+ const summary = emptyStatusSummary();
289
+
290
+ let rows: Array<{ status: NotificationStatus; count: number | string }> = [];
291
+ if (isAdmin) {
292
+ rows = await sql`
293
+ SELECT
294
+ CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
295
+ COUNT(*)::int as count
296
+ FROM notifications.messages
297
+ WHERE created_at >= now() - (${windowDays}::int * interval '1 day')
298
+ GROUP BY status
299
+ `;
300
+ } else if (sentBy) {
301
+ rows = await sql`
302
+ SELECT
303
+ CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
304
+ COUNT(*)::int as count
305
+ FROM notifications.messages
306
+ WHERE sent_by = ${sentBy} AND created_at >= now() - (${windowDays}::int * interval '1 day')
307
+ GROUP BY status
308
+ `;
309
+ }
310
+
311
+ for (const row of rows) {
312
+ const count = typeof row.count === "string" ? Number.parseInt(row.count, 10) : row.count;
313
+ summary[row.status] = Number.isFinite(count) ? count : 0;
314
+ }
315
+
316
+ return summary;
317
+ };
318
+
249
319
  /**
250
320
  * Get a single notification by ID.
251
321
  */
@@ -410,4 +480,5 @@ export const notifications = {
410
480
  update,
411
481
  getPendingSystemCount,
412
482
  sendAllPendingSystem,
483
+ getStatusSummary,
413
484
  };
@@ -438,6 +438,21 @@ export const SETTINGS: SettingDef[] = [
438
438
  group: "mail",
439
439
  templateVars: ["TOKEN", "MAGIC_LINK", "APP_NAME"],
440
440
  },
441
+ {
442
+ key: "mail.ipa_email_login_hint",
443
+ label: "FreeIPA Email Login Hint Template",
444
+ kind: "template",
445
+ default: `<p>A sign-in link was requested for <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{EMAIL}}</code>.</p>
446
+ <p>This email address belongs to a FreeIPA-managed account. Please sign in with your FreeIPA username and password. If your email address is unique in FreeIPA, you can also use it instead of your username.</p>
447
+ <p style="text-align:center;margin:24px 0;">
448
+ <a href="{{LOGIN_URL}}" target="_blank" style="color:#3b82f6;text-decoration:underline;">Open FreeIPA sign-in</a>
449
+ </p>
450
+ <p style="color:#71717a;font-size:12px;margin:0 0 8px 0;">No email login code was created. If you didn't request this, please ignore this email.</p>
451
+ {{#CONTACT_EMAIL}}<p style="color:#71717a;font-size:12px;margin:0;">If you need help, contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
452
+ description: "FreeIPA email-login hint template (HTML). Subject: {{APP_NAME}} FreeIPA Sign In",
453
+ group: "mail",
454
+ templateVars: ["EMAIL", "LOGIN_URL", "CONTACT_EMAIL", "APP_NAME"],
455
+ },
441
456
  {
442
457
  key: "mail.password_reset",
443
458
  label: "Password Reset Template",
@@ -33,6 +33,15 @@ describe("redirect helpers", () => {
33
33
  expect(externalUrl).toBe("https://cloud.example/auth/login?token=token-id");
34
34
  });
35
35
 
36
+ test("builds method-specific login links with safe redirects only", () => {
37
+ const url = createAuthLoginUrl("https://cloud.example", {
38
+ method: "ipa",
39
+ redirectTo: "/app/dashboard",
40
+ });
41
+
42
+ expect(url).toBe("https://cloud.example/auth/login?method=ipa&redirectTo=%2Fapp%2Fdashboard");
43
+ });
44
+
36
45
  test("builds password reset links with safe redirects only", () => {
37
46
  const safeUrl = createAuthPasswordResetUrl("https://cloud.example", {
38
47
  token: "token-id",
@@ -30,9 +30,13 @@ export const createLoginRedirectUrl = (requestUrl: string): string => {
30
30
  };
31
31
 
32
32
  /** Build an absolute auth login URL while preserving only safe local redirects. */
33
- export const createAuthLoginUrl = (appUrl: string, params: { token?: string; redirectTo?: string | null | undefined } = {}): string => {
33
+ export const createAuthLoginUrl = (
34
+ appUrl: string,
35
+ params: { token?: string; method?: "email" | "ipa" | "admin"; redirectTo?: string | null | undefined } = {},
36
+ ): string => {
34
37
  const url = new URL(`${appUrl.replace(/\/$/, "")}/auth/login`);
35
38
  if (params.token) url.searchParams.set("token", params.token);
39
+ if (params.method) url.searchParams.set("method", params.method);
36
40
 
37
41
  const redirectTo = normalizeRedirectTo(params.redirectTo);
38
42
  if (redirectTo) url.searchParams.set("redirectTo", redirectTo);
@@ -9,25 +9,12 @@
9
9
  @custom-variant dark (&:where(.dark, .dark *));
10
10
  @custom-variant light (html:not(.dark) &);
11
11
 
12
- @import "@tabler/icons-webfont/dist/tabler-icons.css";
13
-
14
- /* IBM Plex family Sans for body, Mono for code, Condensed for badges/tags.
15
- Per-weight imports keep the network payload predictable; we deliberately
16
- skip the lighter weights (100–300) and italic-condensed since the design
17
- system never uses them. */
18
- @import "@fontsource/ibm-plex-sans/400.css";
19
- @import "@fontsource/ibm-plex-sans/400-italic.css";
20
- @import "@fontsource/ibm-plex-sans/500.css";
21
- @import "@fontsource/ibm-plex-sans/600.css";
22
- @import "@fontsource/ibm-plex-sans/700.css";
23
- @import "@fontsource/ibm-plex-mono/400.css";
24
- @import "@fontsource/ibm-plex-mono/400-italic.css";
25
- @import "@fontsource/ibm-plex-mono/500.css";
26
- @import "@fontsource/ibm-plex-mono/600.css";
27
- @import "@fontsource/ibm-plex-sans-condensed/400.css";
28
- @import "@fontsource/ibm-plex-sans-condensed/500.css";
29
- @import "@fontsource/ibm-plex-sans-condensed/600.css";
30
- @import "@fontsource/ibm-plex-sans-condensed/700.css";
12
+ /* IBM Plex font-face rules are emitted as `/public/fonts.css` during the core
13
+ build. Keeping them out of global.css avoids Bun inlining the font binaries
14
+ as base64 into the render-blocking stylesheet. */
15
+ /* Tabler icon font rules are emitted as `/public/tabler-icons.css` during the
16
+ core build and preloaded by the shared HTML template to reduce icon flicker
17
+ without changing icon layout semantics. */
31
18
 
32
19
  @import "./tokens.css";
33
20
  @import "./utilities-buttons.css";
@@ -174,7 +161,7 @@
174
161
  beat stdlib's one-class embedded <style> the same way the dark-mode
175
162
  overrides above do. font-family is otherwise unset by stdlib, so the
176
163
  chart would inherit the app sans — we pin the numeric labels to the
177
- platform mono stack (IBM Plex Mono via @fontsource). */
164
+ platform mono stack (IBM Plex Mono via `/public/fonts.css`). */
178
165
  .stdlib-chart .stdlib-chart-tick-label,
179
166
  .stdlib-chart .stdlib-chart-bar-value,
180
167
  .stdlib-chart .stdlib-chart-axis-label {
@@ -146,8 +146,8 @@
146
146
  4. `text-rendering: geometricPrecision` — disables kerning so
147
147
  even non-monospace fallback fonts don't shift glyph X
148
148
  positions across layers. */
149
- /* IBM Plex Mono is shipped via @fontsource (regular + italic + 500
150
- + 600 weights — see global.css). Pinning the font here means the
149
+ /* IBM Plex Mono is shipped via `/public/fonts.css` (regular + italic +
150
+ 500 + 600 weights). Pinning the font here means the
151
151
  editor looks identical across OSes and we control the italic
152
152
  variant precisely. The bold/heading "weight" is faked via
153
153
  text-shadow further down so we never actually request a weight