@valentinkolb/cloud 0.5.0 → 0.5.1
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 +1 -1
- package/src/api/auth.ts +3 -3
- package/src/services/auth-flows/ipa.ts +47 -5
- package/src/services/auth-flows/magic-link.ts +89 -18
- package/src/services/notifications/index.ts +82 -11
- package/src/services/settings/defaults.ts +15 -0
- package/src/shared/redirect.test.ts +9 -0
- package/src/shared/redirect.ts +5 -1
package/package.json
CHANGED
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:
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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 (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 &&
|
|
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
|
|
34
|
-
const
|
|
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:
|
|
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 = ${
|
|
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 = ${
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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",
|
package/src/shared/redirect.ts
CHANGED
|
@@ -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 = (
|
|
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);
|