@k2b/cloud 0.17.0 → 0.19.0
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 +3 -2
- package/src/_internal/define-app.ts +4 -1
- package/src/ai/chat/builtin-tools.tsx +10 -4
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/code-mode-skill.ts +3 -3
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/html-pdf-tool.ts +181 -0
- package/src/ai/markdown-pdf-tool.ts +22 -6
- package/src/ai/pdf-conversions.ts +21 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/api/auth.ts +5 -10
- package/src/browser/app-approval.ts +6 -0
- package/src/contracts/app-approval.ts +11 -0
- package/src/services/accounts/app.ts +17 -4
- package/src/services/accounts/users.ts +26 -12
- package/src/services/app-approval.ts +57 -10
- package/src/services/auth-flows/deferred.ts +41 -0
- package/src/services/auth-flows/magic-link.ts +57 -28
- package/src/services/auth-flows/password-reset.ts +25 -9
- package/src/services/index.ts +1 -1
- package/src/services/pdf/gotenberg.ts +12 -4
- package/src/services/pdf/markdown.ts +3 -1
- package/src/services/pdf/offline-html.ts +122 -0
- package/src/services/pdf/template-preview.ts +3 -1
- package/src/services/providers/local/auth.ts +18 -9
- package/src/services/providers/local/users.ts +44 -11
- package/src/services/session/index.ts +33 -8
- package/src/services/settings/core-settings.ts +7 -0
- package/src/shared/markdown/index.ts +15 -4
- package/src/ssr/LayoutHeader.tsx +20 -42
- package/src/ssr/workspace-navigation.ts +3 -1
- package/src/styles/global.css +5 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline mode for every HTML document Cloud sends to Gotenberg.
|
|
3
|
+
*
|
|
4
|
+
* Chromium in Gotenberg would otherwise run scripts and load every URL the
|
|
5
|
+
* HTML names. The prepended policy allows only inline styles plus local files
|
|
6
|
+
* and data URLs for styles, images, and fonts. Local files are the request's
|
|
7
|
+
* named assets; Gotenberg's default deny list keeps file access inside its
|
|
8
|
+
* working directory under `/tmp`. Removing elements that run code, navigate,
|
|
9
|
+
* embed documents, or open connections is a second layer that does not rely
|
|
10
|
+
* on the policy, which matters for header and footer templates that Chromium
|
|
11
|
+
* renders apart from the document.
|
|
12
|
+
*
|
|
13
|
+
* MathML (`math`) and the SVG elements that hold HTML (`foreignObject`,
|
|
14
|
+
* `desc`) go as well; they have no print use in Cloud documents. SVG shapes,
|
|
15
|
+
* images, `title`, and links stay, so graphics still render.
|
|
16
|
+
*
|
|
17
|
+
* Chromium takes the PDF title from the document's first `<title>`. Without
|
|
18
|
+
* one it falls back to the random name Gotenberg gives the uploaded file,
|
|
19
|
+
* which PDF viewers then show. A caller title fills that gap: it goes ahead
|
|
20
|
+
* of the document, so it becomes the first title, unless the document names
|
|
21
|
+
* itself with a non-blank HTML `<title>`. SVG titles label graphics, template
|
|
22
|
+
* content stays inert, and removed elements take their titles with them, so
|
|
23
|
+
* none of them count. A title of only whitespace character references, such
|
|
24
|
+
* as ` `, still counts and leaves the random name; generated HTML does not
|
|
25
|
+
* write whitespace that way.
|
|
26
|
+
*/
|
|
27
|
+
const POLICY =
|
|
28
|
+
"default-src 'none'; style-src 'unsafe-inline' file: data:; img-src file: data:; font-src file: data:; base-uri 'none'; form-action 'none'";
|
|
29
|
+
const OFFLINE_META = `<meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${POLICY}">`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The caller's doctype when it leads the document after whitespace, comments,
|
|
33
|
+
* and an XML declaration. Repeating it ahead of the policy keeps the caller's
|
|
34
|
+
* rendering mode: HTML without a doctype still renders in quirks mode. Comments
|
|
35
|
+
* end where HTML ends them, including `<!-->` and `<!--->`; `<?…>` ends at the
|
|
36
|
+
* first `>`. A single forward scan keeps the time linear in the input size.
|
|
37
|
+
*/
|
|
38
|
+
const leadingDoctype = (html: string): string => {
|
|
39
|
+
let at = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
while (/\s/.test(html.charAt(at))) at += 1;
|
|
42
|
+
if (html.startsWith("<!--", at)) {
|
|
43
|
+
if (html.startsWith(">", at + 4)) at += 5;
|
|
44
|
+
else if (html.startsWith("->", at + 4)) at += 6;
|
|
45
|
+
else {
|
|
46
|
+
const dashes = html.indexOf("-->", at + 4);
|
|
47
|
+
const bang = html.indexOf("--!>", at + 4);
|
|
48
|
+
if (dashes < 0 && bang < 0) return "";
|
|
49
|
+
at = dashes >= 0 && (bang < 0 || dashes < bang) ? dashes + 3 : bang + 4;
|
|
50
|
+
}
|
|
51
|
+
} else if (html.startsWith("<?", at)) {
|
|
52
|
+
const end = html.indexOf(">", at + 2);
|
|
53
|
+
if (end < 0) return "";
|
|
54
|
+
at = end + 1;
|
|
55
|
+
} else {
|
|
56
|
+
if (html.slice(at, at + 9).toLowerCase() !== "<!doctype") return "";
|
|
57
|
+
const end = html.indexOf(">", at + 9);
|
|
58
|
+
return end < 0 ? "" : html.slice(at, end + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** An empty comment stands in for a removed element, so the text on either side stays separate. */
|
|
64
|
+
const REMOVED = "<!---->";
|
|
65
|
+
|
|
66
|
+
// Chromium parses noscript content as markup when JavaScript is disabled.
|
|
67
|
+
// math, foreignObject, and desc: see the module comment.
|
|
68
|
+
const REMOVED_ELEMENTS = [
|
|
69
|
+
"script",
|
|
70
|
+
"noscript",
|
|
71
|
+
"meta",
|
|
72
|
+
"base",
|
|
73
|
+
"iframe",
|
|
74
|
+
"frame",
|
|
75
|
+
"frameset",
|
|
76
|
+
"object",
|
|
77
|
+
"embed",
|
|
78
|
+
"math",
|
|
79
|
+
"foreignObject",
|
|
80
|
+
"desc",
|
|
81
|
+
];
|
|
82
|
+
const INERT_TITLES = ["svg", "template", ...REMOVED_ELEMENTS].map((element) => `${element} title`).join(", ");
|
|
83
|
+
|
|
84
|
+
const escapeTitle = (title: string): string => title.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
85
|
+
|
|
86
|
+
export const offlineHtml = (html: string, title?: string): string => {
|
|
87
|
+
// Only the text of the first HTML title element names the document.
|
|
88
|
+
let current: { inert: boolean } | undefined;
|
|
89
|
+
let firstSeen = false;
|
|
90
|
+
let named = false;
|
|
91
|
+
const offline = new HTMLRewriter()
|
|
92
|
+
.on("title", {
|
|
93
|
+
element() {
|
|
94
|
+
if (current && !current.inert) firstSeen = true;
|
|
95
|
+
current = { inert: false };
|
|
96
|
+
},
|
|
97
|
+
text(chunk) {
|
|
98
|
+
if (!firstSeen && !current?.inert && chunk.text.trim()) named = true;
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
// Runs after the title handler for the same element, in registration order.
|
|
102
|
+
.on(INERT_TITLES, {
|
|
103
|
+
element() {
|
|
104
|
+
if (current) current.inert = true;
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
.on(REMOVED_ELEMENTS.join(", "), {
|
|
108
|
+
element(element) {
|
|
109
|
+
element.replace(REMOVED, { html: true });
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
.on("link", {
|
|
113
|
+
element(element) {
|
|
114
|
+
// The policy limits a stylesheet to named assets and data URLs. Other
|
|
115
|
+
// link types can preload, prefetch, or connect outside of it.
|
|
116
|
+
if (element.getAttribute("rel")?.trim().toLowerCase() !== "stylesheet") element.replace(REMOVED, { html: true });
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
.transform(html);
|
|
120
|
+
const fallbackTitle = title?.trim() && !named ? `<title>${escapeTitle(title.trim())}</title>` : "";
|
|
121
|
+
return leadingDoctype(html) + OFFLINE_META + fallbackTitle + offline;
|
|
122
|
+
};
|
|
@@ -39,6 +39,8 @@ export type RenderTemplatePdfPreviewInput = {
|
|
|
39
39
|
data: Record<string, unknown>;
|
|
40
40
|
filters?: LiquidTemplateOptions["filters"];
|
|
41
41
|
filename?: string;
|
|
42
|
+
/** PDF document title when the rendered HTML has no `<title>`; see `RenderHtmlToPdfInput.title`. */
|
|
43
|
+
title?: string;
|
|
42
44
|
};
|
|
43
45
|
|
|
44
46
|
export type RenderTemplatePdfPreviewOptions = RenderHtmlToPdfOptions & {
|
|
@@ -89,7 +91,7 @@ export const renderTemplatePdfPreview = async (
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
try {
|
|
92
|
-
const pdfInput = { html, headerHtml, footerHtml, filename: input.filename };
|
|
94
|
+
const pdfInput = { html, title: input.title, headerHtml, footerHtml, filename: input.filename };
|
|
93
95
|
return {
|
|
94
96
|
ok: true,
|
|
95
97
|
html,
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { redis } from "bun";
|
|
2
2
|
|
|
3
|
+
/** Requested by email or username on the login page; resolves the account through its email at use. */
|
|
4
|
+
export type EmailLoginTokenPayload = { email: string; category?: "guest" | "login" };
|
|
5
|
+
/** Issued by an administrator for one account; works without an email address. */
|
|
6
|
+
export type AccountLoginTokenPayload = { userId: string };
|
|
7
|
+
|
|
8
|
+
const loginTokenKey = (token: string) => `email-login:${token}`;
|
|
9
|
+
|
|
3
10
|
export const createMagicLinkToken = async (params: {
|
|
4
11
|
email: string;
|
|
5
12
|
category?: "guest" | "login";
|
|
6
13
|
ttlSeconds?: number;
|
|
7
14
|
}): Promise<string> => {
|
|
8
15
|
const token = crypto.randomUUID();
|
|
9
|
-
await redis.set(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
await redis.set(loginTokenKey(token), JSON.stringify({ email: params.email, category: params.category }), "EX", params.ttlSeconds ?? 300);
|
|
17
|
+
return token;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Shares the email-link key space, so both token kinds use the same link and verify endpoint. */
|
|
21
|
+
export const createAccountLoginToken = async (params: { userId: string; ttlSeconds?: number }): Promise<string> => {
|
|
22
|
+
const token = crypto.randomUUID();
|
|
23
|
+
await redis.set(loginTokenKey(token), JSON.stringify({ userId: params.userId }), "EX", params.ttlSeconds ?? 300);
|
|
15
24
|
return token;
|
|
16
25
|
};
|
|
17
26
|
|
|
18
|
-
export const consumeMagicLinkToken = async (token: string): Promise<
|
|
19
|
-
const raw = await redis.getdel(
|
|
27
|
+
export const consumeMagicLinkToken = async (token: string): Promise<EmailLoginTokenPayload | AccountLoginTokenPayload | null> => {
|
|
28
|
+
const raw = await redis.getdel(loginTokenKey(token));
|
|
20
29
|
if (!raw) return null;
|
|
21
|
-
return JSON.parse(raw) as
|
|
30
|
+
return JSON.parse(raw) as EmailLoginTokenPayload | AccountLoginTokenPayload;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
type PasswordResetPayload = {
|
|
@@ -13,12 +13,32 @@ import * as settings from "../../settings";
|
|
|
13
13
|
type DbRow = Record<string, unknown>;
|
|
14
14
|
|
|
15
15
|
export type LocalUserCreateData = {
|
|
16
|
-
email
|
|
16
|
+
/** Blank or missing stores NULL, which the email policy below allows only for full accounts. */
|
|
17
|
+
email?: string | null;
|
|
17
18
|
givenname?: string;
|
|
18
19
|
sn?: string;
|
|
19
20
|
displayName?: string;
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
/** Created and repaired by the emergency admin login; it never needs an email address. */
|
|
24
|
+
export const EMERGENCY_ADMIN_UID = "admin";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A local account may lack an email only as a full account while
|
|
28
|
+
* `user.local_email_optional` is on. The setting guards writes only, so
|
|
29
|
+
* existing accounts stay valid when it is turned off.
|
|
30
|
+
*/
|
|
31
|
+
const missingEmailError = async (profile: UserProfile, uid?: string): Promise<MutationResult<never> | null> => {
|
|
32
|
+
if (uid === EMERGENCY_ADMIN_UID) return null;
|
|
33
|
+
if (profile !== "user") return { ok: false, error: "Guest accounts need an email address.", status: 400 };
|
|
34
|
+
if (await settings.get<boolean>("user.local_email_optional")) return null;
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: "Local accounts need an email address. Administrators can allow accounts without one in the account settings.",
|
|
38
|
+
status: 400,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
22
42
|
const createLocalUid = async (): Promise<string> => {
|
|
23
43
|
const abbrLen = await settings.get<number>("user.abbr_length");
|
|
24
44
|
return generateUniqueAbbreviation(abbrLen);
|
|
@@ -31,7 +51,11 @@ export const create = async (params: {
|
|
|
31
51
|
admin?: boolean;
|
|
32
52
|
actor?: AuditActor;
|
|
33
53
|
}): Promise<MutationResult<{ id: string }>> => {
|
|
34
|
-
const email = normalizeAccountEmail(params.data.email);
|
|
54
|
+
const email = normalizeAccountEmail(params.data.email ?? "") || null;
|
|
55
|
+
if (!email) {
|
|
56
|
+
const missing = await missingEmailError(params.profile);
|
|
57
|
+
if (missing) return missing;
|
|
58
|
+
}
|
|
35
59
|
const uid = await createLocalUid();
|
|
36
60
|
const admin = resolveStoredAdminState({
|
|
37
61
|
provider: "local",
|
|
@@ -43,8 +67,10 @@ export const create = async (params: {
|
|
|
43
67
|
return await writeLocalAccount(async (tx) => {
|
|
44
68
|
if (!(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
|
|
45
69
|
return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
if (email) {
|
|
71
|
+
await lockAccountEmail(tx, email);
|
|
72
|
+
if ((await findAccountsByEmail(tx, email)).length > 0) return emailAlreadyUsed();
|
|
73
|
+
}
|
|
48
74
|
const rows = await tx<{ id: string }[]>`
|
|
49
75
|
INSERT INTO auth.users (
|
|
50
76
|
uid,
|
|
@@ -118,18 +144,23 @@ export const update = async (params: {
|
|
|
118
144
|
givenname?: string;
|
|
119
145
|
sn?: string;
|
|
120
146
|
displayName?: string;
|
|
121
|
-
|
|
147
|
+
/** `null` or blank removes the address when the email policy below allows it. */
|
|
148
|
+
mail?: string | null;
|
|
122
149
|
};
|
|
123
150
|
}): Promise<MutationResult<void>> => {
|
|
124
151
|
return sql.begin(async (tx) => {
|
|
125
|
-
|
|
152
|
+
const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail ?? "");
|
|
153
|
+
if (email) await lockAccountEmail(tx, email);
|
|
126
154
|
else await tx`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
|
|
127
|
-
const [existing] = await tx<{ mail: string | null }[]>`
|
|
128
|
-
SELECT mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
|
|
155
|
+
const [existing] = await tx<{ uid: string; profile: UserProfile; mail: string | null }[]>`
|
|
156
|
+
SELECT uid, profile, mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
|
|
129
157
|
`;
|
|
130
158
|
if (!existing) return { ok: false, error: "Local user not found", status: 404 };
|
|
131
|
-
const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail);
|
|
132
159
|
const changed = email !== undefined && email !== normalizeAccountEmail(existing.mail ?? "");
|
|
160
|
+
if (changed && !email) {
|
|
161
|
+
const missing = await missingEmailError(existing.profile, existing.uid);
|
|
162
|
+
if (missing) return missing;
|
|
163
|
+
}
|
|
133
164
|
if (changed && email && (await findAccountsByEmail(tx, email)).some((row) => row.id !== params.id)) return emailAlreadyUsed();
|
|
134
165
|
await tx`
|
|
135
166
|
UPDATE auth.users
|
|
@@ -150,8 +181,8 @@ export const setProfile = async (params: {
|
|
|
150
181
|
actor?: AuditActor;
|
|
151
182
|
}): Promise<MutationResult<void>> => {
|
|
152
183
|
const result = await writeLocalAccount(async (tx) => {
|
|
153
|
-
const rows = await tx<{ provider: string; profile: UserProfile; admin: boolean }[]>`
|
|
154
|
-
SELECT provider, profile, admin
|
|
184
|
+
const rows = await tx<{ provider: string; profile: UserProfile; admin: boolean; mail: string | null }[]>`
|
|
185
|
+
SELECT provider, profile, admin, mail
|
|
155
186
|
FROM auth.users
|
|
156
187
|
WHERE id = ${params.id}::uuid
|
|
157
188
|
`;
|
|
@@ -159,6 +190,8 @@ export const setProfile = async (params: {
|
|
|
159
190
|
if (rows[0]!.provider !== "local") {
|
|
160
191
|
return { ok: false, error: "Only local accounts can change profile locally", status: 400 };
|
|
161
192
|
}
|
|
193
|
+
if (params.profile === "guest" && !rows[0]!.mail?.trim())
|
|
194
|
+
return { ok: false, error: "Guest accounts need an email address.", status: 400 };
|
|
162
195
|
if (rows[0]!.profile !== params.profile && !(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
|
|
163
196
|
return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
|
|
164
197
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
4
4
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -6,6 +6,7 @@ import { env } from "../../config/env";
|
|
|
6
6
|
import type { User } from "../../contracts/shared";
|
|
7
7
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
8
8
|
import { isAccountExpired } from "../account-model";
|
|
9
|
+
import { audit } from "../audit";
|
|
9
10
|
import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "../identity/constants";
|
|
10
11
|
import { invalidateIdentitySignerCache, prepareIdentitySigner } from "../identity/key-ring";
|
|
11
12
|
import { getIdentityRuntimeConfig } from "../identity/runtime-config";
|
|
@@ -59,6 +60,17 @@ const requestCached = <T>(
|
|
|
59
60
|
const verifyForRequest = (c: Context, token: string): Promise<CloudSessionClaims | null> =>
|
|
60
61
|
requestCached(verificationByRequest, c, token, () => verifyCredential(token));
|
|
61
62
|
|
|
63
|
+
/** Moves the account's authentication epoch, which ends every session issued before it.
|
|
64
|
+
* Request-time expiry uses only this: device operations check expiry themselves and an
|
|
65
|
+
* administrator can lift it, so revoking devices would depend on which request came first. */
|
|
66
|
+
const endSessions = async (db: SQL, userId: string): Promise<void> => {
|
|
67
|
+
const [updated] = await db<Array<{ id: string }>>`
|
|
68
|
+
UPDATE auth.users SET auth_epoch = auth_epoch + 1
|
|
69
|
+
WHERE id = ${userId}::uuid RETURNING id
|
|
70
|
+
`;
|
|
71
|
+
if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
|
|
72
|
+
};
|
|
73
|
+
|
|
62
74
|
const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
|
|
63
75
|
const { groupsAdmin } = await getIdentityRuntimeConfig();
|
|
64
76
|
const user = await loadJwtSessionUser({
|
|
@@ -69,7 +81,7 @@ const authenticateVerified = async (credential: CloudSessionClaims): Promise<Aut
|
|
|
69
81
|
});
|
|
70
82
|
if (!user) return null;
|
|
71
83
|
if (isAccountExpired(user.accountExpires)) {
|
|
72
|
-
await
|
|
84
|
+
await endSessions(sql, user.id);
|
|
73
85
|
return null;
|
|
74
86
|
}
|
|
75
87
|
return {
|
|
@@ -94,7 +106,7 @@ const authenticateUserId = async (token: string): Promise<string | null> => {
|
|
|
94
106
|
const identity = await loadJwtSessionIdentity({ userId: credential.sub, sid: credential.sid, authEpoch: credential.auth_epoch });
|
|
95
107
|
if (!identity) return null;
|
|
96
108
|
if (isAccountExpired(identity.accountExpires)) {
|
|
97
|
-
await
|
|
109
|
+
await endSessions(sql, identity.userId);
|
|
98
110
|
return null;
|
|
99
111
|
}
|
|
100
112
|
return identity.userId;
|
|
@@ -224,12 +236,25 @@ export const session = {
|
|
|
224
236
|
deleteCookie(c, "session_token", { path: "/" });
|
|
225
237
|
},
|
|
226
238
|
|
|
239
|
+
/** Signs the account out everywhere: every session and every paired sign-in device, audited per device. */
|
|
227
240
|
revokeAllForUser: async (userId: string): Promise<void> => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
241
|
+
await sql.begin(async (tx) => {
|
|
242
|
+
await endSessions(tx, userId);
|
|
243
|
+
// A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
|
|
244
|
+
const devices = await tx<Array<{ id: string }>>`
|
|
245
|
+
UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
246
|
+
`;
|
|
247
|
+
for (const device of devices)
|
|
248
|
+
await audit.record(
|
|
249
|
+
{
|
|
250
|
+
action: "auth.app.device.revoke",
|
|
251
|
+
outcome: "allowed",
|
|
252
|
+
target: { type: "app_device", id: device.id },
|
|
253
|
+
metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
|
|
254
|
+
},
|
|
255
|
+
tx,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
233
258
|
},
|
|
234
259
|
|
|
235
260
|
authenticate: authenticateToken,
|
|
@@ -502,6 +502,13 @@ export const CORE_SETTINGS = {
|
|
|
502
502
|
default: false,
|
|
503
503
|
description: "Allow creating a local guest account automatically during first email sign-in when no local account exists yet.",
|
|
504
504
|
},
|
|
505
|
+
"user.local_email_optional": {
|
|
506
|
+
kind: "boolean",
|
|
507
|
+
label: "Allow local accounts without email",
|
|
508
|
+
default: false,
|
|
509
|
+
description:
|
|
510
|
+
"Administrators may create local full accounts without an email address or remove it later. These accounts sign in with a paired app or a passkey; email sign-in links, welcome emails and email notifications are unavailable to them. Guests and FreeIPA accounts still need an email address. Turning this off leaves existing accounts unchanged.",
|
|
511
|
+
},
|
|
505
512
|
"user.abbr_length": {
|
|
506
513
|
kind: "number",
|
|
507
514
|
label: "Username Abbreviation Length",
|
|
@@ -21,8 +21,9 @@ import { taskListExtension } from "./extensions/task-list";
|
|
|
21
21
|
|
|
22
22
|
// Create a configured marked instance
|
|
23
23
|
type MarkdownProfile = "content" | "help";
|
|
24
|
+
type LinkStyle = "widget" | "plain";
|
|
24
25
|
|
|
25
|
-
const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card") => {
|
|
26
|
+
const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card", links: LinkStyle = "widget") => {
|
|
26
27
|
const marked = new Marked();
|
|
27
28
|
|
|
28
29
|
marked.use({
|
|
@@ -35,7 +36,8 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
|
|
|
35
36
|
marked.use(infoBlocksExtension(notices));
|
|
36
37
|
marked.use(taskListExtension());
|
|
37
38
|
marked.use(tablesExtension());
|
|
38
|
-
marked
|
|
39
|
+
// Plain links keep marked's own renderer: an anchor around the link text.
|
|
40
|
+
if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank" }));
|
|
39
41
|
marked.use(imagesExtension());
|
|
40
42
|
marked.use(katexExtension());
|
|
41
43
|
marked.use(codeExtension());
|
|
@@ -48,15 +50,24 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
|
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
const marked = createMarked();
|
|
51
|
-
const minimalNoticeMarked = createMarked("content", "minimal");
|
|
52
53
|
const helpMarked = createMarked("help");
|
|
54
|
+
const contentMarked = new Map<`${NoticeStyle}:${LinkStyle}`, Marked>([["card:widget", marked]]);
|
|
53
55
|
|
|
54
56
|
export type MarkdownRenderOptions = {
|
|
55
57
|
/** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
|
|
56
58
|
notices?: NoticeStyle;
|
|
59
|
+
/** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
|
|
60
|
+
links?: LinkStyle;
|
|
57
61
|
};
|
|
58
62
|
|
|
59
|
-
const markedFor = (
|
|
63
|
+
const markedFor = ({ notices = "card", links = "widget" }: MarkdownRenderOptions): Marked => {
|
|
64
|
+
const key = `${notices}:${links}` as const;
|
|
65
|
+
const cached = contentMarked.get(key);
|
|
66
|
+
if (cached) return cached;
|
|
67
|
+
const created = createMarked("content", notices, links);
|
|
68
|
+
contentMarked.set(key, created);
|
|
69
|
+
return created;
|
|
70
|
+
};
|
|
60
71
|
|
|
61
72
|
const sanitizeRenderedHtml = (html: string): string =>
|
|
62
73
|
sanitizeHtml(html, {
|
package/src/ssr/LayoutHeader.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ButtonLink } from "@k2b/ui";
|
|
1
|
+
import { ButtonLink, IconButtonLink } from "@k2b/ui";
|
|
2
2
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
3
3
|
import type { CloudTheme } from "../shared/theme";
|
|
4
4
|
import AppLaunchpad, { type AppLaunchpadApp } from "./AppLaunchpad.island";
|
|
@@ -34,17 +34,13 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
34
34
|
style="box-shadow: var(--ui-shadow-surface)"
|
|
35
35
|
>
|
|
36
36
|
<div class="flex min-w-0 items-center gap-2">
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
aria-label={props.homeLabel}
|
|
45
|
-
>
|
|
46
|
-
<img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
|
|
47
|
-
</a>
|
|
37
|
+
{/* Signed-in desktops reach Home through the rail. `lg:hidden` sits on a wrapper because the button's
|
|
38
|
+
own display would win over the utility on the link. */}
|
|
39
|
+
<div class={props.authenticated ? "flex shrink-0 lg:hidden" : "flex shrink-0"}>
|
|
40
|
+
<IconButtonLink href="/" label={props.homeLabel}>
|
|
41
|
+
<img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
|
|
42
|
+
</IconButtonLink>
|
|
43
|
+
</div>
|
|
48
44
|
<div class="hidden min-w-0 items-center lg:flex">
|
|
49
45
|
<LayoutBreadcrumbs breadcrumbs={props.breadcrumbs} />
|
|
50
46
|
</div>
|
|
@@ -66,40 +62,22 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
66
62
|
searchLinks={props.searchLinks}
|
|
67
63
|
searchResources={props.authenticated}
|
|
68
64
|
/>
|
|
65
|
+
{(props.authenticated || props.appLabel) && (
|
|
66
|
+
<AppLaunchpad
|
|
67
|
+
profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
|
|
68
|
+
apps={props.launchpadApps}
|
|
69
|
+
legalLinks={props.legalLinks}
|
|
70
|
+
variant="header"
|
|
71
|
+
label={props.openAppsLabel}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
69
74
|
</div>
|
|
70
75
|
{props.authenticated ? (
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
|
|
75
|
-
apps={props.launchpadApps}
|
|
76
|
-
legalLinks={props.legalLinks}
|
|
77
|
-
variant="header"
|
|
78
|
-
label={props.openAppsLabel}
|
|
79
|
-
/>
|
|
80
|
-
</div>
|
|
81
|
-
<div class="hidden lg:block">
|
|
82
|
-
<ProfilePreferences
|
|
83
|
-
avatarSrc={props.profileAvatarSrc}
|
|
84
|
-
initialTheme={props.theme}
|
|
85
|
-
name={props.profileName}
|
|
86
|
-
placement="header"
|
|
87
|
-
/>
|
|
88
|
-
</div>
|
|
89
|
-
</>
|
|
76
|
+
<div class="hidden lg:block">
|
|
77
|
+
<ProfilePreferences avatarSrc={props.profileAvatarSrc} initialTheme={props.theme} name={props.profileName} placement="header" />
|
|
78
|
+
</div>
|
|
90
79
|
) : (
|
|
91
80
|
<>
|
|
92
|
-
{props.appLabel && (
|
|
93
|
-
<div class="lg:hidden">
|
|
94
|
-
<AppLaunchpad
|
|
95
|
-
profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
|
|
96
|
-
apps={props.launchpadApps}
|
|
97
|
-
legalLinks={props.legalLinks}
|
|
98
|
-
variant="header"
|
|
99
|
-
label={props.openAppsLabel}
|
|
100
|
-
/>
|
|
101
|
-
</div>
|
|
102
|
-
)}
|
|
103
81
|
<LayoutPreferences initialTheme={props.theme} position="bottom-left" />
|
|
104
82
|
<ButtonLink href="/auth/login" size="sm" variant="primary">
|
|
105
83
|
<i class="ti ti-login" aria-hidden="true" />
|
|
@@ -49,12 +49,14 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
|
|
|
49
49
|
try {
|
|
50
50
|
const value: unknown = JSON.parse(owner.textContent ?? "");
|
|
51
51
|
if (!value || typeof value !== "object" || !("items" in value) || !validItems(value.items)) return;
|
|
52
|
-
// SSR links work before the app island loads; action handlers are not yet available
|
|
52
|
+
// SSR links work before the app island loads; action and disclosure handlers are not yet available,
|
|
53
|
+
// so the owner's folds only set where the local disclosure starts.
|
|
53
54
|
const links = (items: readonly NavigationItem[]): NavigationItem[] =>
|
|
54
55
|
items.map((item) => ({
|
|
55
56
|
...item,
|
|
56
57
|
disabled: item.disabled || Boolean(item.action && !item.href),
|
|
57
58
|
...(item.href ? { action: undefined } : {}),
|
|
59
|
+
...(item.expanded !== undefined ? { expanded: undefined, defaultExpanded: item.expanded } : {}),
|
|
58
60
|
...(item.children ? { children: links(item.children) } : {}),
|
|
59
61
|
...(item.actions ? { actions: links(item.actions) } : {}),
|
|
60
62
|
}));
|
package/src/styles/global.css
CHANGED
|
@@ -278,6 +278,11 @@ solid-island {
|
|
|
278
278
|
@media (max-width: 1023px) {
|
|
279
279
|
.cloud-app-canvas {
|
|
280
280
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
281
|
+
}
|
|
282
|
+
/* The flat surface replaces the app gradient in both themes. The dark
|
|
283
|
+
selector matches the specificity of the dark gradient above. */
|
|
284
|
+
.cloud-app-canvas,
|
|
285
|
+
.dark .cloud-app-canvas {
|
|
281
286
|
background: var(--ui-surface);
|
|
282
287
|
}
|
|
283
288
|
/* Below lg the document is the page scroller; reserve its scrollbar gutter
|