fix: use translations for mail templates + fix load translations race condition

pull/8104/head
NGPixel 1 week ago
parent b7dc7b8357
commit a0f47bc3be
No known key found for this signature in database

@ -1245,6 +1245,72 @@ back as the truth. It says so in a banner instead. `selectedProvider` still answ
key whose module has been dropped from the installation, which is the one case the screen cannot key whose module has been dropped from the installation, which is the one case the screen cannot
produce and has to describe. produce and has to describe.
### Emails
The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test
button — and `models/mail.ts` is the only place nodemailer is used. `MailTemplateData` is the
closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did
not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain
code there anyway. A wiki with no SMTP settings is the normal case, which is why `isConfigured` is
a question callers ask rather than something `send()` assumes.
**No mail is written in English in the code.** Every string lives in `locales/en.json` under
`mail.*` and is translated by the same CrowdIn pipeline the interface uses, so a locale somebody
translates arrives in the mails without anything here changing. Adding a template therefore means
adding its keys there, and there is no second place copy is kept.
**`locales.translator(code)` is how strings are resolved server-side** — a bound `{ locale, isRTL,
t }` rather than a `t(locale, key)` call, because the strings have to be fetched from the db and
everything that renders text does it one locale at a time and several strings at a time. Keys and
`{name}` placeholders are vue-i18n's, so a translator need not know which side of the wire a string
is rendered on. It is the first server-side translation in the codebase and is not mail-specific;
anything else the wiki writes for a person rather than for a machine belongs in it too.
- **Two fallbacks, and they are not the same thing.** A code naming a locale that is not installed —
or is not a locale at all — is not used, which is also what stops an unvalidated value off a
request body from putting an entry in the string cache. A locale that IS installed but is missing
the key asked for falls back to `en` for that key alone, because a translation lags the release
that added a string and a half-translated locale must not emit raw keys at a reader.
- **String sets are cached; locale metadata already was.** `getLocales` holds the rows, and
`#stringsFor` holds the blobs — a few thousand entries the interface re-fetches per request and
has no reason to keep, but which a mail reads a handful of keys out of. `reloadCache` drops them,
which every install and update already calls and which the `reloadLocales` event runs on the other
instances of an HA set.
**One description, two bodies.** A template returns a `MailContent` — subject, title, paragraphs, at
most one action, footer — and `htmlShell` and `textBody` are two renderings of it. Each template
used to write both out by hand, and a string changed in one was a string not changed in the other.
Everything in the description appears in both: the title is a heading in the HTML and a first line
in the text, and a paragraph written under a heading refers to it.
**Which language a mail is written in is the caller's answer, not the model's.** `MailRequest.locale`
is what is known about the recipient, and what is known differs at every send site: an account's own
`prefs.locale`, the locale the browser filling the form was reading the wiki in, or nothing at all.
`localeFor` then falls back to the site's primary locale — the wiki's own language, which is the
right answer for a mail nobody has a preference on. The admin area's test button sends in the
language the admin area is being read in, so that it also shows what the templates say in it.
**`prefs.locale` is a language preference, not an interface setting.** It is edited under Profile →
Info and per-user in the admin user editor, and **registration seeds it from the locale the sign-up
form was filled in** — which is the only thing a brand new account has to go on, and means the
preference populates itself for anybody who signed up reading the wiki in their own language. What
the INTERFACE is drawn in is a different question with a different answer: on a page it is the
page's own locale, and elsewhere the locale picker's per-browser choice. See the locale block in
`App.vue`.
**The direction is declared three times on purpose.** Gmail and Outlook.com drop the `<html>` and
`<body>` elements and paste what is between them into their own document, taking any `dir` on them
with it — so an RTL mail read there comes out left-aligned unless the `<td>` that survives carries
the direction itself. The bare URL under a button stays `ltr` either way: a URL is not written in
the language around it, and bidi reordering makes one unreadable.
**Templates are not editable by an administrator**, and the stub that suggested they were — a
`@vue/repl` playground behind the experimental flag, wired to a Save button that did nothing and
importing a package the frontend does not have — is gone. Customization is a separate feature that
has not been built; if it is, the shape to keep is sparse overrides on top of the locale strings
rather than a replacement for them, so that a wiki that rewords one sentence keeps getting
translations and improvements for everything else.
### Audit log ### Audit log
Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind` Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind`

@ -583,7 +583,7 @@ async function routes(app: FastifyInstance) {
*/ */
app.post<{ app.post<{
Params: { siteId: string } Params: { siteId: string }
Body: { strategyId: string; name: string; email: string; password: string } Body: { strategyId: string; name: string; email: string; password: string; locale?: string }
}>( }>(
'/sites/:siteId/auth/register', '/sites/:siteId/auth/register',
{ {
@ -629,6 +629,12 @@ async function routes(app: FastifyInstance) {
type: 'string', type: 'string',
minLength: 8, minLength: 8,
maxLength: 255 maxLength: 255
},
locale: {
type: 'string',
description:
'The language the wiki was being read in while this form was filled. Kept as the new account’s own language preference and used for the mail that follows. Omitted, or naming a locale this wiki has not installed, the site’s own language is used.',
maxLength: 255
} }
} }
}, },
@ -650,7 +656,8 @@ async function routes(app: FastifyInstance) {
email: req.body.email, email: req.body.email,
password: req.body.password, password: req.body.password,
ip: req.ip, ip: req.ip,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }) baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }),
locale: req.body.locale
}, },
req req
) )
@ -754,7 +761,10 @@ async function routes(app: FastifyInstance) {
* for why. What it does report is the two things that are about this wiki rather than about a user: * for why. What it does report is the two things that are about this wiki rather than about a user:
* a strategy that does not offer resets, and an instance with no mail server configured. * a strategy that does not offer resets, and an instance with no mail server configured.
*/ */
app.post<{ Params: { siteId: string }; Body: { strategyId: string; email: string } }>( app.post<{
Params: { siteId: string }
Body: { strategyId: string; email: string; locale?: string }
}>(
'/sites/:siteId/auth/forgotPassword', '/sites/:siteId/auth/forgotPassword',
{ {
config: { config: {
@ -789,6 +799,12 @@ async function routes(app: FastifyInstance) {
type: 'string', type: 'string',
format: 'email', format: 'email',
maxLength: 255 maxLength: 255
},
locale: {
type: 'string',
description:
'The language the wiki was being read in while this form was filled, used for the mail when the account has no language of its own. Omitted, or naming a locale this wiki has not installed, the site’s own language is used.',
maxLength: 255
} }
} }
}, },
@ -812,7 +828,8 @@ async function routes(app: FastifyInstance) {
strategyId: req.body.strategyId, strategyId: req.body.strategyId,
email: req.body.email, email: req.body.email,
ip: req.ip, ip: req.ip,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }) baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }),
locale: req.body.locale
}) })
return { ok: true } return { ok: true }
} catch (err: any) { } catch (err: any) {

@ -153,7 +153,7 @@ async function routes(app: FastifyInstance) {
* password otherwise. Sent through the same transport every other mail goes through, so a failure * password otherwise. Sent through the same transport every other mail goes through, so a failure
* here is the failure they would have hit. * here is the failure they would have hit.
*/ */
app.post<{ Body: { recipient: string } }>( app.post<{ Body: { recipient: string; locale?: string } }>(
'/test', '/test',
{ {
config: { config: {
@ -172,6 +172,12 @@ async function routes(app: FastifyInstance) {
type: 'string', type: 'string',
format: 'email', format: 'email',
maxLength: 255 maxLength: 255
},
locale: {
type: 'string',
description:
'Which language to write the test in, which is how the localized templates are checked. Defaults to the site’s own language, as a mail nobody has a preference on does.',
maxLength: 255
} }
} }
}, },
@ -205,11 +211,16 @@ async function routes(app: FastifyInstance) {
siteId, siteId,
to: req.body.recipient, to: req.body.recipient,
template: 'test', template: 'test',
locale: req.body.locale,
data: { data: {
baseUrl: WIKI.models.mail.baseUrl({ req, siteId }) baseUrl: WIKI.models.mail.baseUrl({ req, siteId })
} }
}) })
await audit(req, 'admin', 'sendTestEmail', { recipient: req.body.recipient, siteId }) await audit(req, 'admin', 'sendTestEmail', {
recipient: req.body.recipient,
siteId,
locale: req.body.locale
})
return { return {
ok: true, ok: true,

@ -152,6 +152,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
pronouns: { pronouns: {
type: 'string' type: 'string'
}, },
locale: {
type: 'string',
description:
'The language the wiki writes to this user in — emails today. An empty string means the language of the site the mail is about.'
},
timezone: { timezone: {
type: 'string', type: 'string',
description: 'IANA time zone name, or an empty string to use the client time zone.' description: 'IANA time zone name, or an empty string to use the client time zone.'
@ -255,6 +260,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string', type: 'string',
maxLength: 255 maxLength: 255
}, },
locale: {
type: 'string',
description:
'The language to be written to in, as a locale code the wiki has installed. An empty string follows the site instead. A code naming no installed locale is ignored when the mail is rendered.',
maxLength: 255
},
timezone: { timezone: {
type: 'string', type: 'string',
description: 'IANA time zone name, e.g. `America/New_York`.', description: 'IANA time zone name, e.g. `America/New_York`.',

@ -63,9 +63,10 @@ const IDENTITY_PROFILE_FIELDS = ['name', 'location', 'jobTitle', 'pronouns'] as
* The rest of the profile: how the wiki behaves for this one person. * The rest of the profile: how the wiki behaves for this one person.
* *
* Never gated on `allowProfileEditing`, because no identity provider owns them — a time zone, a date * Never gated on `allowProfileEditing`, because no identity provider owns them — a time zone, a date
* format and a colour-vision setting are properties of whoever is reading, not of the account record * format, a colour-vision setting and the language to be written to in are properties of whoever is
* an administrator is keeping authoritative. Turning profile editing off to keep names in step with * reading, not of the account record an administrator is keeping authoritative. Turning profile
* a directory must not take somebody's accessibility settings away with it. * editing off to keep names in step with a directory must not take somebody's accessibility settings
* away with it.
*/ */
const PERSONAL_PROFILE_FIELDS = [ const PERSONAL_PROFILE_FIELDS = [
/* /*
@ -75,6 +76,7 @@ const PERSONAL_PROFILE_FIELDS = [
one field that lets anybody be mentioned in a comment. one field that lets anybody be mentioned in a comment.
*/ */
'handle', 'handle',
'locale',
'timezone', 'timezone',
'dateFormat', 'dateFormat',
'timeFormat', 'timeFormat',
@ -266,7 +268,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: "Update the logged in user's own profile", summary: "Update the logged in user's own profile",
description: description:
'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the time zone, date and time formats, appearance and colour-vision settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.', 'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the language, time zone, date and time formats, appearance and colour-vision settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.',
tags: ['Users'], tags: ['Users'],
body: { body: {
$ref: 'UserProfileUpdate#' $ref: 'UserProfileUpdate#'
@ -337,6 +339,7 @@ async function routes(app: FastifyInstance) {
req.session.user = { req.session.user = {
...req.session.user!, ...req.session.user!,
name: profile.name, name: profile.name,
locale: profile.locale,
timezone: profile.timezone, timezone: profile.timezone,
dateFormat: profile.dateFormat, dateFormat: profile.dateFormat,
timeFormat: profile.timeFormat, timeFormat: profile.timeFormat,

@ -806,10 +806,6 @@
"admin.mail.smtpVerifySSL": "Verify SSL Certificate", "admin.mail.smtpVerifySSL": "Verify SSL Certificate",
"admin.mail.smtpVerifySSLHint": "Some hosts requires SSL certificate checking to be disabled. Leave enabled for proper security.", "admin.mail.smtpVerifySSLHint": "Some hosts requires SSL certificate checking to be disabled. Leave enabled for proper security.",
"admin.mail.subtitle": "Configure mail settings", "admin.mail.subtitle": "Configure mail settings",
"admin.mail.templateEditor": "Mail Template Editor",
"admin.mail.templateResetPwd": "Password Reset Email",
"admin.mail.templateWelcome": "Welcome Email",
"admin.mail.templates": "Mail Templates",
"admin.mail.test": "Send a test email", "admin.mail.test": "Send a test email",
"admin.mail.testHint": "Send a test email to ensure your SMTP configuration is working.", "admin.mail.testHint": "Send a test email to ensure your SMTP configuration is working.",
"admin.mail.testRecipient": "Recipient Email Address", "admin.mail.testRecipient": "Recipient Email Address",
@ -1327,6 +1323,9 @@
"admin.users.jobTitle": "Job Title", "admin.users.jobTitle": "Job Title",
"admin.users.jobTitleHint": "The job title of the user.", "admin.users.jobTitleHint": "The job title of the user.",
"admin.users.joined": "Joined", "admin.users.joined": "Joined",
"admin.users.language": "Preferred Language for Communications",
"admin.users.languageHint": "The language to use for emails and notifications that are not locale-specific.",
"admin.users.languageSiteDefault": "Site Default",
"admin.users.lastLoginAt": "Last login {date}", "admin.users.lastLoginAt": "Last login {date}",
"admin.users.lastUpdated": "Last Updated", "admin.users.lastUpdated": "Last Updated",
"admin.users.linkedAccounts": "Linked Accounts", "admin.users.linkedAccounts": "Linked Accounts",
@ -2672,6 +2671,27 @@
"localeFetchDialog.resultUnchanged": "{count} already up to date", "localeFetchDialog.resultUnchanged": "{count} already up to date",
"localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated", "localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated",
"localeFetchDialog.title": "Fetch Updates", "localeFetchDialog.title": "Fetch Updates",
"mail.common.greeting": "Hi {name},",
"mail.resetPwd.action": "Choose a new password",
"mail.resetPwd.body": "Somebody asked to reset the password for your account on {siteName}.",
"mail.resetPwd.expiry": "This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.",
"mail.resetPwd.footer": "You are receiving this because a password reset was requested for this address on {siteName}.",
"mail.resetPwd.subject": "Reset your password \u2014 {siteName}",
"mail.resetPwd.title": "Reset your password",
"mail.test.action": "Go to the wiki",
"mail.test.body": "If you are reading it, {siteName} can send mail through the SMTP server it is configured with.",
"mail.test.footer": "You are receiving this because somebody sent a test email from the Wiki.js admin area.",
"mail.test.subject": "Test email \u2014 {siteName}",
"mail.test.title": "This is a test email",
"mail.welcome.action": "Go to the wiki",
"mail.welcome.body": "Your account on {siteName} is ready. You can sign in at any time.",
"mail.welcome.footer": "You are receiving this because an account was created for this address on {siteName}.",
"mail.welcome.subject": "Welcome to {siteName}",
"mail.welcome.verify.action": "Confirm my email address",
"mail.welcome.verify.body": "An account was created for this address on {siteName}. Confirm that it is yours to finish signing up.",
"mail.welcome.verify.expiry": "This link is valid for 24 hours. If you did not create this account, you can ignore this message.",
"mail.welcome.verify.subject": "Confirm your email address \u2014 {siteName}",
"mail.welcome.verify.title": "Confirm your email address",
"navEdit.clearItems": "Clear All Items", "navEdit.clearItems": "Clear All Items",
"navEdit.editMenuItems": "Edit Menu Items", "navEdit.editMenuItems": "Edit Menu Items",
"navEdit.editingInherited": "Inherited menu — shared with every page using it", "navEdit.editingInherited": "Inherited menu — shared with every page using it",
@ -2799,6 +2819,9 @@
"profile.infoLoadingFailed": "Failed to load your profile.", "profile.infoLoadingFailed": "Failed to load your profile.",
"profile.jobTitle": "Job Title", "profile.jobTitle": "Job Title",
"profile.jobTitleHint": "Your position in your organization; shown on your profile page.", "profile.jobTitleHint": "Your position in your organization; shown on your profile page.",
"profile.language": "Preferred Language for Communications",
"profile.languageHint": "The language to use for emails and notifications that are not locale-specific.",
"profile.languageSiteDefault": "Site Default",
"profile.localeDefault": "Locale Default", "profile.localeDefault": "Locale Default",
"profile.location": "Location", "profile.location": "Location",
"profile.locationHint": "Your city and country; shown on your profile page.", "profile.locationHint": "Your city and country; shown on your profile page.",

@ -21,6 +21,24 @@ const REMOTE_BASE_URL = 'https://github.com/requarks/wiki-locales/raw/main'
*/ */
const SOURCE_LOCALE = 'en' const SOURCE_LOCALE = 'en'
/**
* A `{name}` placeholder in a locale string, as vue-i18n writes them.
*
* A name with nothing to put in it is left as it stands rather than emptied: the string is then
* visibly wrong in the place the mistake actually is, instead of quietly missing a word.
*/
const INTERPOLATION = /\{([A-Za-z0-9_]+)\}/g
/** One locale's strings, bound and ready to render with. See `translator`. */
export interface Translator {
/** The locale these strings are actually for, which need not be the one that was asked for. */
locale: string
/** Whether it is written right to left, which is what a document rendered in it has to declare. */
isRTL: boolean
/** One string, with its `{name}` placeholders filled in. */
t(key: string, params?: Record<string, string | number>): string
}
/** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */ /** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */
interface RemoteLocale { interface RemoteLocale {
file: string file: string
@ -634,9 +652,78 @@ class Locales {
return results.length === 1 ? results[0].strings : [] return results.length === 1 ? results[0].strings : []
} }
/**
* One locale's string set, held in memory after the first read.
*
* Only the locale METADATA is cached by `getLocales`; the strings are a blob of a few thousand
* entries that the interface fetches from the db per request and has no reason to keep. What
* reads them here does: a mail is rendered from a handful of keys, and paying a query for each
* send — of a set that changes only when a locale is installed or updated — is the wrong trade.
*
* Dropped by `reloadCache`, which is what every install and update already calls and what the
* `reloadLocales` event runs on the other instances of an HA set.
*/
async #stringsFor(code: string): Promise<Record<string, string>> {
const cacheKey = `localeStrings:${code}`
const cached = WIKI.cache.get(cacheKey) as Record<string, string> | undefined
if (cached) {
return cached
}
const strings = (await this.getStrings(code)) as Record<string, string>
// -> `getStrings` answers `[]` for a locale that has no row at all
const resolved = Array.isArray(strings) ? {} : (strings ?? {})
WIKI.cache.set(cacheKey, resolved)
return resolved
}
/**
* Resolve strings server-side, the way the interface resolves them in the browser.
*
* Everything the wiki writes for a person to read rather than for a machine to parse belongs in
* `locales/en.json` and is translated with the rest of it — today that is the mails, which used to
* be English literals in `models/mail.ts`. The keys and the `{name}` placeholders are vue-i18n's,
* because a translator working on CrowdIn should not have to know which side of the wire a string
* is rendered on.
*
* **It is a bound translator rather than a `t(locale, key)` call** because the strings have to be
* fetched, and everything that renders text does it one locale at a time and several strings at a
* time: awaiting the locale once and then filling in a template synchronously is what keeps the
* rendering itself readable.
*
* Two fallbacks, and they are not the same thing. A locale that is not installed — or is not a
* locale this wiki has heard of — is not used at all, so that an unvalidated code from a request
* body cannot put an entry in the cache above. A locale that IS installed but is missing the key
* asked for falls back to `en` for that key alone, because a translation lags the release that
* added the string and a half-translated locale must not emit raw keys at a reader.
*
* @param code The locale wanted, if there is one
*/
async translator(code?: string | null): Promise<Translator> {
const known = this.#cachedLocales().find((lc) => lc.code === code && lc.isInstalled)
const locale = known?.code ?? SOURCE_LOCALE
const strings = await this.#stringsFor(locale)
const fallback = locale === SOURCE_LOCALE ? strings : await this.#stringsFor(SOURCE_LOCALE)
return {
locale,
isRTL: known?.isRTL ?? false,
t(key, params = {}) {
// -> The key itself for a string no locale has, which is what vue-i18n shows and is the one
// form of this that says what is missing
const template = strings[key] ?? fallback[key] ?? key
return template.replace(INTERPOLATION, (match, name: string) =>
name in params ? String(params[name]) : match
)
}
}
}
async reloadCache(): Promise<void> { async reloadCache(): Promise<void> {
WIKI.logger.info('Reloading locales cache...') WIKI.logger.info('Reloading locales cache...')
const locales = await WIKI.models.locales.getLocales({ cache: false }) const locales = await WIKI.models.locales.getLocales({ cache: false })
// -> The string sets too, since an update run is exactly what changes them
for (const locale of locales) {
WIKI.cache.del(`localeStrings:${locale.code}`)
}
WIKI.logger.info(`Loaded ${locales.length} locales into cache [ OK ]`) WIKI.logger.info(`Loaded ${locales.length} locales into cache [ OK ]`)
} }
} }

@ -1,13 +1,18 @@
import { createTransport } from 'nodemailer' import { createTransport } from 'nodemailer'
import type { Transporter } from 'nodemailer' import type { Transporter } from 'nodemailer'
import type { Translator } from './locales.ts'
/** /**
* The templates this wiki sends, and what each one needs. * The templates this wiki sends, and what each one needs.
* *
* Two of them are the ones the admin area names under Mail Templates; `test` is the button beside * Two of them belong to a flow — registration and a forgotten password; `test` is the admin area's
* them. Held as literals rather than rows in a table because nothing sends a mail this wiki did not * button. Held as literals rather than rows in a table because nothing sends a mail this wiki did
* ask it to — a template is part of the flow that uses it, and a flow that gained one would have to * not ask it to — a template is part of the flow that uses it, and a flow that gained one would
* gain code here anyway. * have to gain code here anyway.
*
* **What each one SAYS is not here**: every string lives in `locales/en.json` under `mail.*` and is
* translated with the rest of the interface, so adding a template means adding its keys there. See
* `render`.
*/ */
export interface MailTemplateData { export interface MailTemplateData {
welcome: { welcome: {
@ -39,11 +44,23 @@ export interface MailTemplateData {
/** A template key, i.e. one of the keys of `MailTemplateData`. */ /** A template key, i.e. one of the keys of `MailTemplateData`. */
export type MailTemplate = keyof MailTemplateData export type MailTemplate = keyof MailTemplateData
/** What a rendered template is: a subject line and the two bodies every mail carries. */ /**
interface RenderedMail { * One mail as its template describes it, before either body exists.
*
* Every mail this wiki sends is the same shape — a heading, some paragraphs, at most one thing to
* press — so a template says what goes in those slots and nothing about how they are drawn. Which
* is what lets the HTML body and the text body be two renderings of one description rather than
* two hand-written copies that drift: the pair of them used to be written out per template, and a
* string changed in one was a string not changed in the other.
*/
interface MailContent {
subject: string subject: string
text: string /** The heading, which is the subject without the site's name repeated in it. */
html: string title: string
/** Paragraphs, as plain text: escaping is the business of whichever body they end up in. */
body: string[]
action?: { label: string; url: string }
footer: string
} }
/** /**
@ -76,6 +93,17 @@ export interface MailRequest<K extends MailTemplate = MailTemplate> {
to: string to: string
template: K template: K
data: MailTemplateData[K] data: MailTemplateData[K]
/**
* What language to write it in, when anything is known about the recipient's.
*
* The caller's job rather than this model's, because what is known differs at every send site and
* none of it is reachable from here: an account's own `prefs.locale`, the locale the browser
* making the request was reading the wiki in, or nothing at all for a mail nobody asked for. A
* locale that is not installed is ignored, so a value straight off a request body is safe to pass.
*
* Left empty, the mail is written in the site's primary locale — see `localeFor`.
*/
locale?: string | null
} }
/** /**
@ -98,33 +126,33 @@ function escapeHtml(str: string): string {
* Written as a table with inline styles and no external anything, which is what a mail client will * Written as a table with inline styles and no external anything, which is what a mail client will
* actually render — the stylesheet, the web font and the background image a page would use are all * actually render — the stylesheet, the web font and the background image a page would use are all
* either stripped or blocked by the ones people read mail in. * either stripped or blocked by the ones people read mail in.
*
* **The direction is declared three times on purpose.** Gmail and Outlook.com drop the `<html>` and
* `<body>` elements and paste what is between them into their own document, taking any `dir` on
* them with it — so a right-to-left mail read there would come out left-aligned, with its
* punctuation at the wrong end, unless the cell that survives carries the direction itself.
*/ */
function htmlShell({ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean): string {
title, const dir = isRTL ? 'rtl' : 'ltr'
body, const align = isRTL ? 'right' : 'left'
action,
footer
}: {
title: string
/** Paragraphs, already escaped. */
body: string[]
action?: { label: string; url: string }
footer: string
}): string {
const paragraphs = body const paragraphs = body
.map((p) => `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${p}</p>`) .map(
(p) =>
`<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${escapeHtml(p)}</p>`
)
.join('') .join('')
const button = action const button = action
? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` + ? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` +
// -> The same link in full, for the client that will not render the button and for the reader // -> The same link in full, for the client that will not render the button and for the reader
// who wants to see where it goes before following it // who wants to see where it goes before following it. Always left to right: a URL is not
`<p style="margin:0 0 16px;font-size:12px;line-height:1.6;color:#78909c;word-break:break-all;">${escapeHtml(action.url)}</p>` // written in the language around it, and bidi reordering makes one unreadable.
`<p dir="ltr" style="margin:0 0 16px;font-size:12px;line-height:1.6;color:#78909c;word-break:break-all;text-align:${align};">${escapeHtml(action.url)}</p>`
: '' : ''
return [ return [
'<!DOCTYPE html>', '<!DOCTYPE html>',
'<html><body style="margin:0;padding:24px;background:#eceff1;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">', `<html dir="${dir}"><body dir="${dir}" style="margin:0;padding:24px;background:#eceff1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">`,
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:6px;">', '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:6px;">',
'<tr><td style="padding:32px;">', `<tr><td dir="${dir}" style="padding:32px;text-align:${align};">`,
`<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`, `<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`,
paragraphs, paragraphs,
button, button,
@ -133,6 +161,23 @@ function htmlShell({
].join('') ].join('')
} }
/**
* The text body, which is the same description with nothing drawn around it.
*
* Every client that will not render HTML shows this one, and it is also what keeps a mail out of
* the spam folder a filter puts HTML-only messages in. Every field of the description appears,
* including the title: it is a heading in the HTML body and a first line here, and a paragraph
* written under one refers to it — "if you are reading it" has nothing to point at in a text body
* that opened with the sentence itself.
*
* The action's URL goes after the paragraphs rather than beside whichever sentence introduces it,
* so that the two bodies say things in the same order — the button sits after the paragraphs in
* the HTML one for the same reason.
*/
function textBody({ title, body, action, footer }: MailContent): string {
return [title, ...body, ...(action ? [action.url] : []), footer].join('\n\n')
}
/** /**
* Mail model * Mail model
* *
@ -149,6 +194,11 @@ function htmlShell({
* The transport is built once and kept, and rebuilt when the settings behind it change — * The transport is built once and kept, and rebuilt when the settings behind it change —
* `configFingerprint()` is how that is noticed, rather than an event, because the settings can be * `configFingerprint()` is how that is noticed, rather than an event, because the settings can be
* changed on another instance in an HA set and this one would never hear about it. * changed on another instance in an HA set and this one would never hear about it.
*
* **Nothing here is written in English.** Every string comes out of `locales/en.json` under `mail.*`
* through `locales.translator`, which is the same string set and the same CrowdIn pipeline the
* interface uses — so a locale somebody translates arrives in the mails as well, and a template
* added here is a set of keys added there. Which language one mail is written in is `localeFor`.
*/ */
class Mail { class Mail {
private transporter: Transporter | null = null private transporter: Transporter | null = null
@ -272,118 +322,76 @@ class Mail {
} }
/** /**
* Render one of the templates. * The language a mail is written in.
*
* What the caller knows about the recipient, and the site's primary locale when it knows nothing
* — which is the wiki's own language, and the right answer for a mail about a site rather than
* one addressed to a reader with a preference. `translator` takes it from there: a code naming a
* locale that is not installed falls back to English rather than sending a mail full of keys.
*/
private localeFor(locale: string | null | undefined, siteId: string): string | null {
return locale || WIKI.sites[siteId]?.config?.locales?.primary || null
}
/**
* Describe one of the templates in the locale it is being sent in.
* *
* Both bodies are built from the same values: the text one is what a client that will not render * Strings come from `locales/en.json` under `mail.*` and are translated with the rest of the
* HTML shows, and is also what keeps the mail out of a spam folder that scores HTML-only mail. * interface, so what is left here is which keys a template uses and what it puts in them. Both
* bodies are rendered from the one description that comes out — see `MailContent`.
*/ */
private render<K extends MailTemplate>( private render<K extends MailTemplate>(
{ t }: Translator,
siteName: string, siteName: string,
template: K, template: K,
data: MailTemplateData[K] data: MailTemplateData[K]
): RenderedMail { ): MailContent {
switch (template) { switch (template) {
case 'welcome': { case 'welcome': {
const d = data as MailTemplateData['welcome'] const d = data as MailTemplateData['welcome']
const footer = `You are receiving this because an account was created for this address on ${siteName}.`
if (d.verifyUrl) { if (d.verifyUrl) {
return { return {
subject: `Confirm your email address — ${siteName}`, subject: t('mail.welcome.verify.subject', { siteName }),
text: [ title: t('mail.welcome.verify.title'),
`Hi ${d.name},`,
'',
`An account was created for this address on ${siteName}. Confirm that it is yours to finish signing up:`,
'',
d.verifyUrl,
'',
'This link is valid for 24 hours. If you did not create this account, you can ignore this message.',
'',
footer
].join('\n'),
html: htmlShell({
title: 'Confirm your email address',
body: [ body: [
`Hi ${escapeHtml(d.name)},`, t('mail.common.greeting', { name: d.name }),
`An account was created for this address on ${escapeHtml(siteName)}. Confirm that it is yours to finish signing up.`, t('mail.welcome.verify.body', { siteName }),
'This link is valid for 24 hours. If you did not create this account, you can ignore this message.' t('mail.welcome.verify.expiry')
], ],
action: { label: 'Confirm my email address', url: d.verifyUrl }, action: { label: t('mail.welcome.verify.action'), url: d.verifyUrl },
footer footer: t('mail.welcome.footer', { siteName })
})
} }
} }
return { return {
subject: `Welcome to ${siteName}`, subject: t('mail.welcome.subject', { siteName }),
text: [ title: t('mail.welcome.subject', { siteName }),
`Hi ${d.name},`, body: [t('mail.common.greeting', { name: d.name }), t('mail.welcome.body', { siteName })],
'', action: { label: t('mail.welcome.action'), url: `${d.baseUrl}/login` },
`Your account on ${siteName} is ready. You can sign in at any time:`, footer: t('mail.welcome.footer', { siteName })
'',
`${d.baseUrl}/login`,
'',
footer
].join('\n'),
html: htmlShell({
title: `Welcome to ${escapeHtml(siteName)}`,
body: [
`Hi ${escapeHtml(d.name)},`,
'Your account is ready. You can sign in at any time.'
],
action: { label: 'Go to the wiki', url: `${d.baseUrl}/login` },
footer
})
} }
} }
case 'resetPwd': { case 'resetPwd': {
const d = data as MailTemplateData['resetPwd'] const d = data as MailTemplateData['resetPwd']
const footer = `You are receiving this because a password reset was requested for this address on ${siteName}.`
return { return {
subject: `Reset your password — ${siteName}`, subject: t('mail.resetPwd.subject', { siteName }),
text: [ title: t('mail.resetPwd.title'),
`Hi ${d.name},`,
'',
`Somebody asked to reset the password for your account on ${siteName}. Choose a new one here:`,
'',
d.resetUrl,
'',
'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.',
'',
footer
].join('\n'),
html: htmlShell({
title: 'Reset your password',
body: [ body: [
`Hi ${escapeHtml(d.name)},`, t('mail.common.greeting', { name: d.name }),
`Somebody asked to reset the password for your account on ${escapeHtml(siteName)}.`, t('mail.resetPwd.body', { siteName }),
'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.' t('mail.resetPwd.expiry')
], ],
action: { label: 'Choose a new password', url: d.resetUrl }, action: { label: t('mail.resetPwd.action'), url: d.resetUrl },
footer footer: t('mail.resetPwd.footer', { siteName })
})
} }
} }
default: { default: {
const d = data as MailTemplateData['test'] const d = data as MailTemplateData['test']
const footer =
'You are receiving this because somebody sent a test email from the Wiki.js admin area.'
return { return {
subject: `Test email — ${siteName}`, subject: t('mail.test.subject', { siteName }),
text: [ title: t('mail.test.title'),
'This is a test email.', body: [t('mail.test.body', { siteName })],
'', action: { label: t('mail.test.action'), url: d.baseUrl },
`If you are reading it, ${siteName} can send mail through the SMTP server it is configured with.`, footer: t('mail.test.footer')
'',
d.baseUrl,
'',
footer
].join('\n'),
html: htmlShell({
title: 'This is a test email',
body: [
`If you are reading it, ${escapeHtml(siteName)} can send mail through the SMTP server it is configured with.`
],
footer
})
} }
} }
} }
@ -403,15 +411,20 @@ class Mail {
siteId, siteId,
to, to,
template, template,
data data,
locale
}: MailRequest<K>): Promise<void> { }: MailRequest<K>): Promise<void> {
if (!this.isConfigured) { if (!this.isConfigured) {
throw new Error('ERR_MAIL_NOT_CONFIGURED') throw new Error('ERR_MAIL_NOT_CONFIGURED')
} }
const conf = this.config const conf = this.config
const siteName = this.siteName(siteId) const siteName = this.siteName(siteId)
const { subject, text, html } = this.render(siteName, template, data) const translator = await WIKI.models.locales.translator(this.localeFor(locale, siteId))
WIKI.logger.debug(`Sending ${template} email to <${to}>...`) const content = this.render(translator, siteName, template, data)
const { subject } = content
const text = textBody(content)
const html = htmlShell(content, translator.isRTL)
WIKI.logger.debug(`Sending ${template} email to <${to}> in ${translator.locale}...`)
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({
from: { from: {
name: conf.senderName?.trim() || siteName, name: conf.senderName?.trim() || siteName,

@ -120,6 +120,11 @@ export interface UserProfile {
location: string location: string
jobTitle: string jobTitle: string
pronouns: string pronouns: string
/**
* The language this wiki writes to this person in, or an empty string to be written to in
* whatever the site's own language is. See `prefs.locale` in `createUser`.
*/
locale: string
timezone: string timezone: string
dateFormat: string dateFormat: string
timeFormat: string timeFormat: string
@ -155,6 +160,7 @@ export interface UserProfilePatch {
location?: string location?: string
jobTitle?: string jobTitle?: string
pronouns?: string pronouns?: string
locale?: string
timezone?: string timezone?: string
dateFormat?: string dateFormat?: string
timeFormat?: string timeFormat?: string
@ -164,7 +170,14 @@ export interface UserProfilePatch {
/** The `meta` keys the profile owns, and the `prefs` keys it owns. */ /** The `meta` keys the profile owns, and the `prefs` keys it owns. */
const profileMetaKeys = ['location', 'jobTitle', 'pronouns'] as const const profileMetaKeys = ['location', 'jobTitle', 'pronouns'] as const
const profilePrefsKeys = ['timezone', 'dateFormat', 'timeFormat', 'appearance', 'cvd'] as const const profilePrefsKeys = [
'locale',
'timezone',
'dateFormat',
'timeFormat',
'appearance',
'cvd'
] as const
/** /**
* The square, in pixels, an avatar is resized to. The profile page and the account menu both display * The square, in pixels, an avatar is resized to. The profile page and the account menu both display
@ -486,7 +499,8 @@ class Users {
isVerified = true, isVerified = true,
isProvisioned = false, isProvisioned = false,
externalId, externalId,
strategyId strategyId,
locale = ''
}: { }: {
name: string name: string
email: string email: string
@ -520,6 +534,14 @@ class Users {
* it under the strategy that was registered through, rather than assuming the built-in one. * it under the strategy that was registered through, rather than assuming the built-in one.
*/ */
strategyId?: string strategyId?: string
/**
* The language to write to this person in, where the account was created somewhere that knows
* — a registration knows which language the wiki was being read in when the form was filled.
*
* Empty for an account created for somebody who is not there to say, which leaves the mails in
* the site's own language until they pick one on their profile.
*/
locale?: string
}): Promise<string> { }): Promise<string> {
const localStrategyId = strategyId ?? WIKI.data.systemIds.localAuthId const localStrategyId = strategyId ?? WIKI.data.systemIds.localAuthId
const result = await WIKI.db const result = await WIKI.db
@ -550,6 +572,7 @@ class Users {
pronouns: '' pronouns: ''
}, },
prefs: { prefs: {
locale,
// -> Seeded from the instance-wide user defaults, which an administrator can change // -> Seeded from the instance-wide user defaults, which an administrator can change
timezone: WIKI.config.userDefaults?.timezone ?? 'America/New_York', timezone: WIKI.config.userDefaults?.timezone ?? 'America/New_York',
dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD', dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD',
@ -621,6 +644,9 @@ class Users {
location: meta.location ?? '', location: meta.location ?? '',
jobTitle: meta.jobTitle ?? '', jobTitle: meta.jobTitle ?? '',
pronouns: meta.pronouns ?? '', pronouns: meta.pronouns ?? '',
// -> An empty locale means "whatever the site is written in", which is what the mails fall
// back to and what the profile page offers as the first choice
locale: prefs.locale ?? '',
// -> An empty time zone / date format means "whatever the client resolves", which is what the // -> An empty time zone / date format means "whatever the client resolves", which is what the
// profile page falls back to // profile page falls back to
timezone: prefs.timezone ?? '', timezone: prefs.timezone ?? '',
@ -2062,6 +2088,10 @@ class Users {
* sent a link, and `verifyEmail` is what comes back: there is nothing to log in to yet. * sent a link, and `verifyEmail` is what comes back: there is nothing to log in to yet.
* *
* @param baseUrl Where this wiki is reachable, for the link in the email * @param baseUrl Where this wiki is reachable, for the link in the email
* @param locale What language the wiki was being read in while the form was filled. It is kept as
* the account's own preference as well as used for the mail that follows: somebody
* who signed up reading the wiki in French has said something about which language
* to write to them in, and there is nowhere else for a brand new account to get one.
* @throws `ERR_INVALID_STRATEGY`, `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`, * @throws `ERR_INVALID_STRATEGY`, `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`,
* `ERR_ACCOUNT_ALREADY_EXISTS`, `ERR_PASSWORD_TOO_SHORT`, `ERR_MAIL_NOT_CONFIGURED` * `ERR_ACCOUNT_ALREADY_EXISTS`, `ERR_PASSWORD_TOO_SHORT`, `ERR_MAIL_NOT_CONFIGURED`
*/ */
@ -2073,7 +2103,8 @@ class Users {
email, email,
password, password,
ip, ip,
baseUrl baseUrl,
locale
}: { }: {
siteId: string siteId: string
strategyId: string strategyId: string
@ -2082,6 +2113,7 @@ class Users {
password: string password: string
ip?: string ip?: string
baseUrl: string baseUrl: string
locale?: string | null
}, },
req: any req: any
): Promise<AfterLoginResult> { ): Promise<AfterLoginResult> {
@ -2142,7 +2174,8 @@ class Users {
password, password,
groups: strategy.autoEnrollGroups ?? [], groups: strategy.autoEnrollGroups ?? [],
isVerified: !mustVerify, isVerified: !mustVerify,
strategyId: strategy.id strategyId: strategy.id,
locale: locale ?? ''
}) })
/* /*
@ -2169,6 +2202,7 @@ class Users {
siteId, siteId,
to: address, to: address,
template: 'welcome', template: 'welcome',
locale,
data: { data: {
name: name.trim(), name: name.trim(),
baseUrl, baseUrl,
@ -2207,6 +2241,7 @@ class Users {
siteId, siteId,
to: address, to: address,
template: 'welcome', template: 'welcome',
locale,
data: { name: name.trim(), baseUrl } data: { name: name.trim(), baseUrl }
}) })
} catch (err: any) { } catch (err: any) {
@ -2254,6 +2289,8 @@ class Users {
siteId: targetSiteId, siteId: targetSiteId,
to: user.email, to: user.email,
template: 'welcome', template: 'welcome',
// -> Theirs, never the administrator's: the request that triggers this is somebody else's
locale: (user.prefs as Record<string, any>)?.locale,
data: { data: {
name: user.name, name: user.name,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: targetSiteId }) baseUrl: WIKI.models.mail.baseUrl({ req, siteId: targetSiteId })
@ -2301,6 +2338,8 @@ class Users {
* both misconfigurations rather than answers about a user. * both misconfigurations rather than answers about a user.
* *
* @param baseUrl Where this wiki is reachable, for the link in the email * @param baseUrl Where this wiki is reachable, for the link in the email
* @param locale What language the wiki was being read in when the form was filled, which is what
* the mail is written in for an account whose owner has never picked one
* @throws `ERR_INVALID_STRATEGY`, `ERR_FORGOT_PASSWORD_DISABLED`, `ERR_MAIL_NOT_CONFIGURED` * @throws `ERR_INVALID_STRATEGY`, `ERR_FORGOT_PASSWORD_DISABLED`, `ERR_MAIL_NOT_CONFIGURED`
*/ */
async requestPasswordReset({ async requestPasswordReset({
@ -2308,13 +2347,15 @@ class Users {
strategyId, strategyId,
email, email,
ip, ip,
baseUrl baseUrl,
locale
}: { }: {
siteId: string siteId: string
strategyId: string strategyId: string
email: string email: string
ip?: string ip?: string
baseUrl: string baseUrl: string
locale?: string | null
}): Promise<void> { }): Promise<void> {
const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId) const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId)
if (!strategy || strategy.module !== 'local') { if (!strategy || strategy.module !== 'local') {
@ -2349,6 +2390,10 @@ class Users {
siteId, siteId,
to: user.email, to: user.email,
template: 'resetPwd', template: 'resetPwd',
// -> Their own preference where they have one, and otherwise the language the wiki was being
// read in by whoever filled the form — which is the same person often enough to be the
// better guess, and is only ever a guess either way
locale: (user.prefs as Record<string, any>)?.locale || locale,
data: { data: {
name: user.name, name: user.name,
baseUrl, baseUrl,
@ -2435,6 +2480,7 @@ class Users {
email: user.email, email: user.email,
name: user.name, name: user.name,
hasAvatar: user.hasAvatar, hasAvatar: user.hasAvatar,
locale: user.prefs?.locale,
timezone: user.prefs?.timezone, timezone: user.prefs?.timezone,
dateFormat: user.prefs?.dateFormat, dateFormat: user.prefs?.dateFormat,
timeFormat: user.prefs?.timeFormat, timeFormat: user.prefs?.timeFormat,

@ -27,6 +27,7 @@ declare module 'fastify' {
email: string email: string
name: string name: string
hasAvatar?: boolean hasAvatar?: boolean
locale?: string
timezone?: string timezone?: string
dateFormat?: string dateFormat?: string
timeFormat?: string timeFormat?: string

@ -153,13 +153,20 @@ const state = reactive({
*/ */
const GUESTS_GROUP_ID = '10000000-0000-4000-8000-000000000001' const GUESTS_GROUP_ID = '10000000-0000-4000-8000-000000000001'
const expirations = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the expiry choices showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const expirations = computed(() => [
{ value: '30d', text: t('admin.api.expiration30d') }, { value: '30d', text: t('admin.api.expiration30d') },
{ value: '90d', text: t('admin.api.expiration90d') }, { value: '90d', text: t('admin.api.expiration90d') },
{ value: '180d', text: t('admin.api.expiration180d') }, { value: '180d', text: t('admin.api.expiration180d') },
{ value: '1y', text: t('admin.api.expiration1y') }, { value: '1y', text: t('admin.api.expiration1y') },
{ value: '3y', text: t('admin.api.expiration3y') } { value: '3y', text: t('admin.api.expiration3y') }
] ])
// REFS // REFS

@ -551,6 +551,7 @@ import { copyToClipboard } from '@/helpers/clipboard'
import { localizeError } from '@/helpers/localization' import { localizeError } from '@/helpers/localization'
import { useAuthConfigStore } from '@/stores/authConfig' import { useAuthConfigStore } from '@/stores/authConfig'
import { useCommonStore } from '@/stores/common'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -566,6 +567,7 @@ const dark = useDark()
// STORES // STORES
const authConfigStore = useAuthConfigStore() const authConfigStore = useAuthConfigStore()
const commonStore = useCommonStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -1050,7 +1052,14 @@ async function forgotPassword() {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/forgotPassword`, { const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/forgotPassword`, {
json: { json: {
strategyId: state.selectedStrategyId, strategyId: state.selectedStrategyId,
email: state.username email: state.username,
/*
What language to write the mail in, for an account whose owner has never said. There is
nothing else to go on at this point -- the form is filled by somebody who is not signed in
-- and the language the wiki is being read in right now is a better guess than the site's
own, which is what the server falls back to.
*/
locale: commonStore.locale
}, },
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json() }).json()
@ -1139,7 +1148,10 @@ async function register() {
strategyId: state.selectedStrategyId, strategyId: state.selectedStrategyId,
name: state.newName, name: state.newName,
email: state.newEmail, email: state.newEmail,
password: state.newPassword password: state.newPassword,
// -> Kept as the new account's own language preference, as well as used for the welcome
// mail: signing up while reading the wiki in French says which language to write in
locale: commonStore.locale
}, },
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json() }).json()

@ -834,7 +834,14 @@ const state = reactive({
usersTotal: 0 usersTotal: 0
}) })
const sections = [ /*
Computed, not plain arrays: the locale strings are fetched after the app mounts (`App.vue` ->
`applyLocale`), so a `t()` called once during `setup()` can resolve before they land and leave
the tab strip and the member table showing raw keys for the life of the page — which is what a direct load of this
screen does, as opposed to a navigation to it. Inside a computed they re-evaluate when
`setLocaleMessage` fills the strings in.
*/
const sections = computed(() => [
{ key: 'overview', text: t('admin.groups.overview'), icon: 'la:users' }, { key: 'overview', text: t('admin.groups.overview'), icon: 'la:users' },
{ key: 'rules', text: t('admin.groups.rules'), icon: 'la:file-invoice', rulesTotal: true }, { key: 'rules', text: t('admin.groups.rules'), icon: 'la:file-invoice', rulesTotal: true },
{ {
@ -850,9 +857,9 @@ const sections = [
usersTotal: true, usersTotal: true,
excludeGuests: true excludeGuests: true
} }
] ])
const usersHeaders = [ const usersHeaders = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -888,7 +895,7 @@ const usersHeaders = [
sortable: false, sortable: false,
style: 'width: 250px' style: 'width: 250px'
} }
] ])
/** /**
* The group-wide permissions, as cards, in the order the screen offers them. * The group-wide permissions, as cards, in the order the screen offers them.

@ -1,170 +0,0 @@
<template>
<w-layout view="hHh lpR fFf" container>
<w-header class="card-header px-4 py-2">
<w-icon name="img:/_assets/icons/fluent-template.svg" left size="md" />
<span>{{ t(`admin.mail.templateEditor`) }}</span>
<w-space />
<w-btn
class="mr-2"
flat
rounded
color="white"
:aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle"
:href="siteStore.docsBase + `/system/mail`"
target="_blank"
type="a" />
<w-btn-group push>
<w-btn
push
color="white"
text-color="grey-7"
:label="t(`common.actions.cancel`)"
:aria-label="t(`common.actions.cancel`)"
icon="la:times"
@click="close" />
<w-btn
push
color="positive"
text-color="white"
:label="t(`common.actions.save`)"
:aria-label="t(`common.actions.save`)"
icon="la:check"
:disabled="state.loading > 0" />
</w-btn-group>
</w-header>
<w-page-container>
<w-page>
<!-- ------------------------------------------------------- -->
<!-- MONACO EDITOR -->
<!-- ------------------------------------------------------- -->
<div class="mail-template-editor">
<repl
:editor="Monaco"
:store="store"
:show-ts-config="false"
theme="dark"
:auto-resize="true"
:ssr="false"
:show-compile-output="false" />
</div>
<w-inner-loading :showing="state.loading > 0">
<w-spinner color="accent" size="lg" />
</w-inner-loading>
</w-page>
</w-page-container>
</w-layout>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site'
import { cloneDeep } from 'es-toolkit/object'
import { debounce } from 'es-toolkit/function'
import { Repl, ReplStore, File } from '@vue/repl'
import Monaco from '@vue/repl/monaco-editor'
import '@vue/repl/style.css'
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
loading: 0
})
const store = new ReplStore({
// initialize repl with previously serialized state
serializedState: location.hash.slice(1),
// starts on the output pane (mobile only) if the URL has a showOutput query
showOutput: false,
// starts on a different tab on the output pane if the URL has a outputMode query
// and default to the "preview" tab
outputMode: 'preview'
})
let editor
const monacoRef = ref(null)
// METHODS
function close() {
adminStore.$patch({ overlay: '' })
}
// MOUNTED
// onMounted(async () => {
// setTimeout(() => {
// // -> Define Monaco Theme
// monaco.editor.defineTheme('wikijs', {
// base: 'vs-dark',
// inherit: true,
// rules: [],
// colors: {
// 'editor.background': '#070a0d',
// 'editor.lineHighlightBackground': '#0d1117',
// 'editorLineNumber.foreground': '#546e7a',
// 'editorGutter.background': '#0d1117'
// }
// })
// // -> Initialize Monaco Editor
// editor = monaco.editor.create(monacoRef.value, {
// automaticLayout: true,
// cursorBlinking: 'blink',
// // cursorSmoothCaretAnimation: true,
// fontSize: 16,
// formatOnType: true,
// language: 'markdown',
// lineNumbersMinChars: 4,
// padding: { top: 10, bottom: 10 },
// scrollBeyondLastLine: false,
// tabSize: 2,
// theme: 'wikijs',
// value: '',
// wordWrap: 'on'
// })
// // -> Handle content change
// // editor.onDidChangeModelContent(debounce(ev => {
// // editor.getValue()
// // }, 500))
// // -> Post init
// editor.focus()
// console.info('BOB')
// }, 1000)
// })
// onBeforeUnmount(() => {
// if (editor) {
// editor.dispose()
// }
// })
</script>
<style lang="scss">
.mail-template-editor {
height: calc(100vh - 101px);
display: block;
position: relative;
> div {
height: 100%;
}
}
</style>

@ -579,10 +579,17 @@ const sortableOptions = {
animation: 150 animation: 150
} }
const visibilityOptions = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the visibility choices showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const visibilityOptions = computed(() => [
{ value: false, label: t('navEdit.visibilityAll') }, { value: false, label: t('navEdit.visibilityAll') },
{ value: true, label: t('navEdit.visibilityLimited') } { value: true, label: t('navEdit.visibilityLimited') }
] ])
// COMPUTED // COMPUTED

@ -182,7 +182,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { confirm } from '@/composables/dialog' import { confirm } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
@ -224,7 +224,14 @@ const state = reactive({
tmpl: null tmpl: null
}) })
const inventoryMisc = [ /*
Computed, not plain arrays: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the field inventory showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
they re-evaluate when `setLocaleMessage` fills the strings in.
*/
const inventoryMisc = computed(() => [
{ {
key: 'header', key: 'header',
label: t('editor.pageData.fieldTypeHeader'), label: t('editor.pageData.fieldTypeHeader'),
@ -235,9 +242,9 @@ const inventoryMisc = [
label: t('editor.pageData.fieldTypeImage'), label: t('editor.pageData.fieldTypeImage'),
icon: 'la:image' icon: 'la:image'
} }
] ])
const inventoryKV = [ const inventoryKV = computed(() => [
{ {
key: 'text', key: 'text',
label: t('editor.pageData.fieldTypeText'), label: t('editor.pageData.fieldTypeText'),
@ -258,7 +265,7 @@ const inventoryKV = [
label: t('editor.pageData.fieldTypeLink'), label: t('editor.pageData.fieldTypeLink'),
icon: 'la:link' icon: 'la:link'
} }
] ])
// REFS // REFS

@ -72,6 +72,7 @@ const props = defineProps({
const roots = inject('roots') const roots = inject('roots')
const nodes = inject('nodes') const nodes = inject('nodes')
const selection = inject('selection') const selection = inject('selection')
// -> A computed, not a plain array: see the note where `TreeNav` provides it
const contextActionList = inject('contextActionList') const contextActionList = inject('contextActionList')
// COMPOSABLES // COMPOSABLES
@ -84,7 +85,7 @@ const rootContextActionList = computed(() => {
if (props.parentId) { if (props.parentId) {
return [] return []
} }
return contextActionList.filter((c) => c.key === 'newFolder') return contextActionList.value.filter((c) => c.key === 'newFolder')
}) })
const level = computed(() => { const level = computed(() => {

@ -50,7 +50,14 @@ const { t } = useI18n()
// Context Actions // Context Actions
const contextActions = { /*
Computed, and PROVIDED as a computed: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the right-click menu showing raw keys for the life of the page — which is what a
direct load of a screen carrying this tree does, as opposed to a navigation to it. Wrapping the
labels alone would not be enough: the list handed to the descendants is built here too.
*/
const contextActions = computed(() => ({
newFolder: { newFolder: {
icon: 'la:plus-circle', icon: 'la:plus-circle',
iconColor: 'blue', iconColor: 'blue',
@ -82,16 +89,18 @@ const contextActions = {
label: t('common.actions.delete'), label: t('common.actions.delete'),
labelColor: 'negative' labelColor: 'negative'
} }
} }))
provide( provide(
'contextActionList', 'contextActionList',
computed(() =>
props.contextActionList.map((key) => ({ props.contextActionList.map((key) => ({
key, key,
...contextActions[key], ...contextActions.value[key],
handler: (nodeId) => { handler: (nodeId) => {
emit('contextAction', nodeId, key) emit('contextAction', nodeId, key)
} }
})) }))
)
) )
// DATA // DATA

@ -81,6 +81,8 @@ const opened = inject('opened')
const displayMode = inject('displayMode') const displayMode = inject('displayMode')
const selection = inject('selection') const selection = inject('selection')
const emitLazyLoad = inject('emitLazyLoad') const emitLazyLoad = inject('emitLazyLoad')
// -> A computed, not a plain array: see the note where `TreeNav` provides it. Unwrapped by the
// template, which is the only place it is read here.
const contextActionList = inject('contextActionList') const contextActionList = inject('contextActionList')
// DATA // DATA

@ -90,7 +90,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
@ -109,18 +109,25 @@ const state = reactive({
const menuRef = ref(null) const menuRef = ref(null)
const dateFormats = [ /*
Computed, not plain arrays: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave both selects showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
they re-evaluate when `setLocaleMessage` fills the strings in.
*/
const dateFormats = computed(() => [
{ value: '', label: t('profile.localeDefault') }, { value: '', label: t('profile.localeDefault') },
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY' }, { value: 'DD.MM.YYYY', label: 'DD.MM.YYYY' },
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
{ value: 'YYYY/MM/DD', label: 'YYYY/MM/DD' } { value: 'YYYY/MM/DD', label: 'YYYY/MM/DD' }
] ])
const timeFormats = [ const timeFormats = computed(() => [
{ value: '12h', label: t('admin.general.defaultTimeFormat12h') }, { value: '12h', label: t('admin.general.defaultTimeFormat12h') },
{ value: '24h', label: t('admin.general.defaultTimeFormat24h') } { value: '24h', label: t('admin.general.defaultTimeFormat24h') }
] ])
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
// METHODS // METHODS

@ -173,6 +173,26 @@
</w-card> </w-card>
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta"> <w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
<w-card-header>{{ t('admin.users.preferences') }}</w-card-header> <w-card-header>{{ t('admin.users.preferences') }}</w-card-header>
<w-item>
<blueprint-icon icon="translation" />
<w-item-section>
<w-item-label>{{ t(`admin.users.language`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.languageHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-select
outlined
v-model="state.user.prefs.locale"
:disable="!canManage"
:options="languages"
emit-value
map-options
dense
options-dense
:aria-label="t(`admin.users.language`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item> <w-item>
<blueprint-icon icon="timezone" /> <blueprint-icon icon="timezone" />
<w-item-section> <w-item-section>
@ -713,6 +733,7 @@ import { notify } from '@/composables/notify'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
@ -728,6 +749,7 @@ const dark = useDark()
const adminStore = useAdminStore() const adminStore = useAdminStore()
const flagsStore = useFlagsStore() const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
// ROUTER // ROUTER
@ -755,14 +777,21 @@ const state = reactive({
metadataInvalidJSON: false metadataInvalidJSON: false
}) })
const sections = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the tab strip showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const sections = computed(() => [
{ key: 'overview', text: t('admin.users.overview'), icon: 'la:user' }, { key: 'overview', text: t('admin.users.overview'), icon: 'la:user' },
{ key: 'activity', text: t('admin.users.activity'), icon: 'la:chart-area', disabled: true }, { key: 'activity', text: t('admin.users.activity'), icon: 'la:chart-area', disabled: true },
{ key: 'auth', text: t('admin.users.auth'), icon: 'la:key' }, { key: 'auth', text: t('admin.users.auth'), icon: 'la:key' },
{ key: 'groups', text: t('admin.users.groups'), icon: 'la:users' }, { key: 'groups', text: t('admin.users.groups'), icon: 'la:users' },
{ key: 'metadata', text: t('admin.users.metadata'), icon: 'la:clipboard-list' }, { key: 'metadata', text: t('admin.users.metadata'), icon: 'la:clipboard-list' },
{ key: 'operations', text: t('admin.users.operations'), icon: 'la:tools' } { key: 'operations', text: t('admin.users.operations'), icon: 'la:tools' }
] ])
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
@ -775,6 +804,16 @@ const timezones = Intl.supportedValuesOf('timeZone')
*/ */
const canManage = computed(() => userStore.can('manage:users')) const canManage = computed(() => userStore.can('manage:users'))
/*
Every INSTALLED locale, not one site's active ones: this screen is not opened on behalf of a site,
and an account may well be welcomed to a different one from the one the admin area is being read
on. Empty is the language of whichever site the mail turns out to be about.
*/
const languages = computed(() => [
{ label: t('admin.users.languageSiteDefault'), value: '' },
...siteStore.installedLocales.map((lc) => ({ label: lc.displayName, value: lc.code }))
])
/* /*
Whether this user may move somebody in or out of a given group. Whether this user may move somebody in or out of a given group.

@ -506,7 +506,6 @@ const overlays = {
loader: () => import('../components/GroupEditOverlay.vue'), loader: () => import('../components/GroupEditOverlay.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric
}), }),
// MailTemplateEditorOverlay: defineAsyncComponent({ loader: () => import('../components/MailTemplateEditorOverlay.vue'), loadingComponent: LoadingGeneric }),
UserEditOverlay: defineAsyncComponent({ UserEditOverlay: defineAsyncComponent({
loader: () => import('../components/UserEditOverlay.vue'), loader: () => import('../components/UserEditOverlay.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric

@ -31,7 +31,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { watch } from 'vue' import { computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
@ -77,7 +77,14 @@ useMeta(() => {
// DATA // DATA
const sidenav = [ /*
Computed, not a plain array: the locale strings are fetched after the app mounts (`App.vue` ->
`applyLocale`), so a `t()` called once during `setup()` can resolve before they land and leave
the section list showing raw keys for the life of the page — which is what a direct load of this
screen does, as opposed to a navigation to it. Inside a computed it re-evaluates when
`setLocaleMessage` fills the strings in.
*/
const sidenav = computed(() => [
{ {
key: 'messages', key: 'messages',
label: t('inbox.inbox'), label: t('inbox.inbox'),
@ -93,7 +100,7 @@ const sidenav = [
label: t('inbox.pendingReview'), label: t('inbox.pendingReview'),
icon: 'la:clipboard-check' icon: 'la:clipboard-check'
} }
] ])
// WATCHERS // WATCHERS

@ -116,7 +116,14 @@ useMeta(() => {
// DATA // DATA
const sidenav = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts (`App.vue` ->
`applyLocale`), so a `t()` called once during `setup()` can resolve before they land and leave the
whole section list showing raw keys for the life of the page. That is what a profile URL opened
directly does, as opposed to one navigated to from a page the app had already drawn. Inside a
computed it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const sidenav = computed(() => [
{ {
key: 'info', key: 'info',
label: t('profile.title'), label: t('profile.title'),
@ -155,7 +162,7 @@ const sidenav = [
icon: 'la:history', icon: 'la:history',
disabled: true disabled: true
} }
] ])
const state = reactive({ const state = reactive({
/** Whether the section list is open. Only consulted below 900px, where it is a disclosure. */ /** Whether the section list is open. Only consulted below 900px, where it is a disclosure. */
@ -182,7 +189,7 @@ const isNavCollapsed = computed(() => !isAtLeast900.value)
*/ */
const currentSection = computed(() => { const currentSection = computed(() => {
return ( return (
sidenav.find((item) => route.path === `/_profile/${item.key}`) ?? { sidenav.value.find((item) => route.path === `/_profile/${item.key}`) ?? {
label: t('profile.title'), label: t('profile.title'),
icon: 'la:user-circle' icon: 'la:user-circle'
} }

@ -607,7 +607,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue' import { computed, onMounted, reactive, watch } from 'vue'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
@ -712,7 +712,14 @@ const state = reactive({
config: defaultConfig() config: defaultConfig()
}) })
const contentLicenses = [ /*
Computed, not plain arrays: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave each of these selects showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
they re-evaluate when `setLocaleMessage` fills the strings in.
*/
const contentLicenses = computed(() => [
{ value: '', text: t('common.license.none') }, { value: '', text: t('common.license.none') },
{ value: 'alr', text: t('common.license.alr') }, { value: 'alr', text: t('common.license.alr') },
{ value: 'cc0', text: t('common.license.cc0') }, { value: 'cc0', text: t('common.license.cc0') },
@ -722,22 +729,22 @@ const contentLicenses = [
{ value: 'ccbync', text: t('common.license.ccbync') }, { value: 'ccbync', text: t('common.license.ccbync') },
{ value: 'ccbyncsa', text: t('common.license.ccbyncsa') }, { value: 'ccbyncsa', text: t('common.license.ccbyncsa') },
{ value: 'ccbyncnd', text: t('common.license.ccbyncnd') } { value: 'ccbyncnd', text: t('common.license.ccbyncnd') }
] ])
const ratingsModes = [ const ratingsModes = computed(() => [
{ value: 'off', label: t('admin.general.ratingsOff') }, { value: 'off', label: t('admin.general.ratingsOff') },
{ value: 'thumbs', label: t('admin.general.ratingsThumbs') }, { value: 'thumbs', label: t('admin.general.ratingsThumbs') },
{ value: 'stars', label: t('admin.general.ratingsStars') } { value: 'stars', label: t('admin.general.ratingsStars') }
] ])
const reasonForChangeModes = [ const reasonForChangeModes = computed(() => [
{ value: 'off', label: t('admin.general.reasonForChangeOff') }, { value: 'off', label: t('admin.general.reasonForChangeOff') },
{ value: 'optional', label: t('admin.general.reasonForChangeOptional') }, { value: 'optional', label: t('admin.general.reasonForChangeOptional') },
{ value: 'required', label: t('admin.general.reasonForChangeRequired') } { value: 'required', label: t('admin.general.reasonForChangeRequired') }
] ])
const uploadConflictBehaviors = [ const uploadConflictBehaviors = computed(() => [
{ value: 'overwrite', label: t('admin.general.uploadConflictBehaviorOverwrite') }, { value: 'overwrite', label: t('admin.general.uploadConflictBehaviorOverwrite') },
{ value: 'reject', label: t('admin.general.uploadConflictBehaviorReject') }, { value: 'reject', label: t('admin.general.uploadConflictBehaviorReject') },
{ value: 'new', label: t('admin.general.uploadConflictBehaviorNew') } { value: 'new', label: t('admin.general.uploadConflictBehaviorNew') }
] ])
const rulesTitle = [(val) => /^[^<>"]+$/.test(val) || t('admin.general.siteTitleInvalidChars')] const rulesTitle = [(val) => /^[^<>"]+$/.test(val) || t('admin.general.siteTitleInvalidChars')]
const rulesHostname = [ const rulesHostname = [

@ -184,7 +184,14 @@ const state = reactive({
search: '' search: ''
}) })
const headers = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the table header row showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const headers = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -215,7 +222,7 @@ const headers = [
sortable: false, sortable: false,
style: 'width: 250px' style: 'width: 250px'
} }
] ])
// WATCHERS // WATCHERS

@ -102,7 +102,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
@ -133,7 +133,14 @@ const state = reactive({
loading: 0 loading: 0
}) })
const instancesHeaders = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the table header row showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const instancesHeaders = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -178,7 +185,7 @@ const instancesHeaders = [
sortable: true, sortable: true,
format: relativeDate format: relativeDate
} }
] ])
// METHODS // METHODS

@ -294,45 +294,6 @@
</w-card> </w-card>
</div> </div>
<div class="col-span-12 lg:col-span-5"> <div class="col-span-12 lg:col-span-5">
<!-- ----------------------- -->
<!-- MAIL TEMPLATES -->
<!-- ----------------------- -->
<w-card class="pb-2 mb-4" v-if="flagStore.experimental">
<w-card-header>{{ t('admin.mail.templates') }}</w-card-header>
<w-list>
<w-item>
<blueprint-icon icon="resume-template" />
<w-item-section>
<w-item-label>{{ t(`admin.mail.templateWelcome`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
outline
no-caps
icon="la:edit"
color="primary"
@click="editTemplate(`welcome`)"
:label="t(`common.actions.edit`)" />
</w-item-section>
</w-item>
<w-separator inset />
<w-item>
<blueprint-icon icon="resume-template" />
<w-item-section>
<w-item-label>{{ t(`admin.mail.templateResetPwd`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
outline
no-caps
icon="la:edit"
color="primary"
@click="editTemplate(`pwdreset`)"
:label="t(`common.actions.edit`)" />
</w-item-section>
</w-item>
</w-list>
</w-card>
<!-- ----------------------- --> <!-- ----------------------- -->
<!-- SMTP TEST --> <!-- SMTP TEST -->
<!-- ----------------------- --> <!-- ----------------------- -->
@ -375,7 +336,7 @@ import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags' import { useCommonStore } from '@/stores/common'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { toMerged } from 'es-toolkit/object' import { toMerged } from 'es-toolkit/object'
@ -383,7 +344,7 @@ import { toMerged } from 'es-toolkit/object'
// STORES // STORES
const adminStore = useAdminStore() const adminStore = useAdminStore()
const flagStore = useFlagsStore() const commonStore = useCommonStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -492,13 +453,6 @@ async function save() {
state.loading-- state.loading--
} }
function editTemplate(tmplId) {
adminStore.$patch({
overlayOpts: { id: tmplId },
overlay: 'MailTemplateEditorOverlay'
})
}
async function sendTest() { async function sendTest() {
if (!state.testEmail) { if (!state.testEmail) {
notify({ notify({
@ -513,7 +467,10 @@ async function sendTest() {
// saved is not what is being tested // saved is not what is being tested
const resp = await API_CLIENT.post('mail/test', { const resp = await API_CLIENT.post('mail/test', {
json: { json: {
recipient: state.testEmail recipient: state.testEmail,
// -> The language the admin area is being read in, so that the test is also a look at what
// the templates say in it
locale: commonStore.locale
} }
}).json() }).json()
if (!resp?.ok) { if (!resp?.ok) {

@ -403,7 +403,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, watch } from 'vue' import { computed, onMounted, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
@ -455,7 +455,14 @@ const MODE_STATES = {
failed: ['failed', 'interrupted'] failed: ['failed', 'interrupted']
} }
const scheduledJobsHeaders = [ /*
Computed, not plain arrays: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave every table header row showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
they re-evaluate when `setLocaleMessage` fills the strings in.
*/
const scheduledJobsHeaders = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -507,9 +514,9 @@ const scheduledJobsHeaders = [
sortable: false, sortable: false,
style: 'width: 15px;' style: 'width: 15px;'
} }
] ])
const upcomingJobsHeaders = [ const upcomingJobsHeaders = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -561,9 +568,9 @@ const upcomingJobsHeaders = [
sortable: false, sortable: false,
style: 'width: 15px;' style: 'width: 15px;'
} }
] ])
const jobsHeaders = [ const jobsHeaders = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -614,7 +621,7 @@ const jobsHeaders = [
sortable: false, sortable: false,
style: 'width: 15px;' style: 'width: 15px;'
} }
] ])
// WATCHERS // WATCHERS

@ -245,7 +245,14 @@ const state = reactive({
totalPages: 15 totalPages: 15
}) })
const headers = [ /*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they
land and leave the table header row showing raw keys for the life of the page — which is what a
direct load of this screen does, as opposed to a navigation to it. Inside a computed
it re-evaluates when `setLocaleMessage` fills the strings in.
*/
const headers = computed(() => [
{ {
align: 'center', align: 'center',
field: 'id', field: 'id',
@ -281,7 +288,7 @@ const headers = [
sortable: false, sortable: false,
style: 'width: 250px' style: 'width: 250px'
} }
] ])
// WATCHERS // WATCHERS

@ -201,6 +201,26 @@
:aria-label="t(`profile.appearance`)" /> :aria-label="t(`profile.appearance`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator inset spaced="sm" />
<w-item>
<blueprint-icon icon="translation" />
<w-item-section>
<w-item-label>{{ t(`profile.language`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.languageHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-select
v-model="state.config.locale"
outlined
emit-value
map-options
dense
options-dense
hide-bottom-space
:aria-label="t(`profile.language`)"
:options="languages" />
</w-item-section>
</w-item>
<div class="w-section-header mt-6">{{ t('profile.accessibility') }}</div> <div class="w-section-header mt-6">{{ t('profile.accessibility') }}</div>
<w-item> <w-item>
<blueprint-icon icon="visualy-impaired" /> <blueprint-icon icon="visualy-impaired" />
@ -244,11 +264,13 @@ import { loading } from '@/composables/loading'
import { computed, onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { useAuthConfigStore } from '@/stores/authConfig' import { useAuthConfigStore } from '@/stores/authConfig'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
// STORES // STORES
const authConfigStore = useAuthConfigStore() const authConfigStore = useAuthConfigStore()
const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
// I18N // I18N
@ -271,6 +293,7 @@ const state = reactive({
location: '', location: '',
jobTitle: '', jobTitle: '',
pronouns: '', pronouns: '',
locale: '',
timezone: '', timezone: '',
dateFormat: '', dateFormat: '',
timeFormat: '12h', timeFormat: '12h',
@ -280,29 +303,47 @@ const state = reactive({
loading: 0 loading: 0
}) })
const dateFormats = [ /*
Computed, not plain arrays: the locale strings are fetched after the app mounts (`App.vue` ->
`applyLocale`), so a `t()` called once during `setup()` can resolve before they land and leave
every one of these selects showing raw keys for the life of the page -- which is exactly what a
profile screen opened by a full page load, rather than navigated to, does. Inside a computed they
re-evaluate when `setLocaleMessage` fills the strings in.
*/
/*
The site's own languages, and nothing else: those are the ones this wiki can actually be written
in. Named the way the locale picker in the sidebar names them -- natively, or by whatever an
administrator renamed them to -- so the two lists read alike.
*/
const languages = computed(() => [
{ value: '', label: t('profile.languageSiteDefault') },
...siteStore.locales.active.map((lc) => ({ value: lc.code, label: lc.displayName }))
])
const dateFormats = computed(() => [
{ value: '', label: t('profile.localeDefault') }, { value: '', label: t('profile.localeDefault') },
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY' }, { value: 'DD.MM.YYYY', label: 'DD.MM.YYYY' },
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
{ value: 'YYYY/MM/DD', label: 'YYYY/MM/DD' } { value: 'YYYY/MM/DD', label: 'YYYY/MM/DD' }
] ])
const timeFormats = [ const timeFormats = computed(() => [
{ value: '12h', label: t('admin.general.defaultTimeFormat12h') }, { value: '12h', label: t('admin.general.defaultTimeFormat12h') },
{ value: '24h', label: t('admin.general.defaultTimeFormat24h') } { value: '24h', label: t('admin.general.defaultTimeFormat24h') }
] ])
const appearances = [ const appearances = computed(() => [
{ value: 'site', label: t('profile.appearanceDefault') }, { value: 'site', label: t('profile.appearanceDefault') },
{ value: 'light', label: t('profile.appearanceLight') }, { value: 'light', label: t('profile.appearanceLight') },
{ value: 'dark', label: t('profile.appearanceDark') } { value: 'dark', label: t('profile.appearanceDark') }
] ])
const cvdChoices = [ const cvdChoices = computed(() => [
{ value: 'none', label: t('profile.cvdNone') }, { value: 'none', label: t('profile.cvdNone') },
{ value: 'protanopia', label: t('profile.cvdProtanopia') }, { value: 'protanopia', label: t('profile.cvdProtanopia') },
{ value: 'deuteranopia', label: t('profile.cvdDeuteranopia') }, { value: 'deuteranopia', label: t('profile.cvdDeuteranopia') },
{ value: 'tritanopia', label: t('profile.cvdTritanopia') } { value: 'tritanopia', label: t('profile.cvdTritanopia') }
] ])
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
const canEdit = computed(() => authConfigStore.allowProfileEditing) const canEdit = computed(() => authConfigStore.allowProfileEditing)
@ -336,6 +377,7 @@ function applyProfile(profile) {
state.config.location = profile.location || '' state.config.location = profile.location || ''
state.config.jobTitle = profile.jobTitle || '' state.config.jobTitle = profile.jobTitle || ''
state.config.pronouns = profile.pronouns || '' state.config.pronouns = profile.pronouns || ''
state.config.locale = profile.locale || ''
// -> No stored time zone means "whatever the browser resolves" // -> No stored time zone means "whatever the browser resolves"
state.config.timezone = profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '' state.config.timezone = profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || ''
state.config.dateFormat = profile.dateFormat || '' state.config.dateFormat = profile.dateFormat || ''
@ -366,6 +408,7 @@ async function save() {
} }
: {}), : {}),
handle: state.config.handle.trim(), handle: state.config.handle.trim(),
locale: state.config.locale,
timezone: state.config.timezone, timezone: state.config.timezone,
dateFormat: state.config.dateFormat, dateFormat: state.config.dateFormat,
timeFormat: state.config.timeFormat, timeFormat: state.config.timeFormat,
@ -383,6 +426,7 @@ async function save() {
// app shell, so saving them takes effect right away // app shell, so saving them takes effect right away
userStore.$patch({ userStore.$patch({
name: state.config.name, name: state.config.name,
locale: state.config.locale,
timezone: state.config.timezone, timezone: state.config.timezone,
dateFormat: state.config.dateFormat, dateFormat: state.config.dateFormat,
timeFormat: state.config.timeFormat, timeFormat: state.config.timeFormat,

@ -88,7 +88,15 @@ export const useUserStore = defineStore('user', {
email: '', email: '',
name: '', name: '',
hasAvatar: false, hasAvatar: false,
localeCode: '', /**
* The language the wiki writes to this user in — the mails, today. Empty means the language of
* whichever site the mail is about.
*
* Deliberately not what the interface is drawn in: that follows the page being read, and on a
* screen that is not a page the locale picker's own choice, which `commonStore` keeps per
* browser. See the locale block in `App.vue`.
*/
locale: '',
timezone: '', timezone: '',
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
timeFormat: '12h', timeFormat: '12h',
@ -135,6 +143,7 @@ export const useUserStore = defineStore('user', {
location: resp.location || '', location: resp.location || '',
jobTitle: resp.jobTitle || '', jobTitle: resp.jobTitle || '',
pronouns: resp.pronouns || '', pronouns: resp.pronouns || '',
locale: resp.locale || '',
timezone: resp.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '', timezone: resp.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
dateFormat: resp.dateFormat || '', dateFormat: resp.dateFormat || '',
timeFormat: resp.timeFormat || '12h', timeFormat: resp.timeFormat || '12h',
@ -165,6 +174,7 @@ export const useUserStore = defineStore('user', {
email: '', email: '',
name: '', name: '',
hasAvatar: false, hasAvatar: false,
locale: '',
timezone: '', timezone: '',
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
timeFormat: '12h', timeFormat: '12h',

Loading…
Cancel
Save