feat: add allow passkeys + allow profile editing as global settings + various fixes

scarlett
NGPixel 2 days ago
parent 795414ef71
commit ad8e9d393f
No known key found for this signature in database

@ -1048,6 +1048,9 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req, reply) => { async (req, reply) => {
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try { try {
const { authOptions, pending } = await WIKI.models.passkeys.startLogin({ const { authOptions, pending } = await WIKI.models.passkeys.startLogin({
hostname: req.hostname, hostname: req.hostname,
@ -1112,6 +1115,11 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req, reply) => { async (req, reply) => {
// -> The challenge is refused too, so this is the case of one issued before the setting was
// turned off. A passkey stops being a way in the moment it is: nothing here is deleted.
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try { try {
const result = await WIKI.models.passkeys.verifyLogin( const result = await WIKI.models.passkeys.verifyLogin(
{ {
@ -1465,6 +1473,82 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* GET THE INSTANCE-WIDE AUTHENTICATION CONFIGURATION
*/
app.get(
'/authentication/config',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the instance-wide authentication settings',
description:
'The settings that hold for every site: whether passkeys may be used, and whether a user may edit their own profile. Which strategies a site offers is part of that sites configuration instead.',
tags: ['Authentication'],
response: {
200: { $ref: 'AuthConfig#' }
}
}
},
async () => {
return WIKI.models.authentication.getConfig()
}
)
/**
* UPDATE THE INSTANCE-WIDE AUTHENTICATION CONFIGURATION
*/
app.put<{ Body: Record<string, any> }>(
'/authentication/config',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the instance-wide authentication settings',
description:
'Accepts any subset of the fields, and applies at once on every instance — nothing here is read at boot. Turning passkeys off leaves the registered ones in place: they cannot be used while it is off, and work again as soon as it is back on.',
tags: ['Authentication'],
body: { $ref: 'AuthConfig#' },
response: {
200: {
description: 'Authentication configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch = WIKI.models.authentication.pickFields(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No valid authentication setting was provided.')
}
if (!(await WIKI.models.authentication.updateConfig(patch))) {
return reply.internalServerError('Failed to save the authentication configuration.')
}
// -> Recorded in full, as the security settings are: neither of these is a secret, and what
// they were set to is exactly what gets asked about after somebody loses a way in
await audit(req, 'admin', 'updateAuthConfig', patch)
return {
ok: true,
message: 'Authentication configuration saved successfully.'
}
}
)
/** /**
* LIST AUTHENTICATION MODULES * LIST AUTHENTICATION MODULES
*/ */

@ -4,14 +4,15 @@ import type { FastifyInstance } from 'fastify'
/** /**
* Bootstrap API Route * Bootstrap API Route
* *
* The three things the app has to know before it can draw anything: which site it is on, which system * The things the app has to know before it can draw anything: which site it is on, which system flags
* flags are set, and who is asking. Each has an endpoint of its own the admin area reads the flags, * are set, how the instance authenticates, and who is asking. Each has an endpoint of its own the
* the login flow asks who is logged in once that has changed but a full load needs all three at * admin area reads the flags, the login flow asks who is logged in once that has changed but a full
* once, and asking for them one at a time is three round trips before the first pixel. * load needs all of them at once, and asking one at a time is that many round trips before the first
* pixel.
* *
* None of them touches the database: the site configurations, the flags and the locale list are in * None of them touches the database: the site configurations, the flags, the authentication settings
* memory, and the session carries the user. So what this saves is the round trips, which is the whole * and the locale list are in memory, and the session carries the user. So what this saves is the
* cost. * round trips, which is the whole cost.
*/ */
async function routes(app: FastifyInstance) { async function routes(app: FastifyInstance) {
app.get<{ Querystring: { hostname?: string } }>( app.get<{ Querystring: { hostname?: string } }>(
@ -23,7 +24,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Everything the app needs to start', summary: 'Everything the app needs to start',
description: description:
'The site for the hostname, the system flags, and the current session — the same answers `sites/{hostname}`, `system/flags` and `users/whoami` give, in one request.\n\nCarries the session, so it is never cached.', 'The site for the hostname, the system flags, the instance-wide authentication settings and the current session — the same answers `sites/{hostname}`, `system/flags`, `authentication/config` and `users/whoami` give, in one request.\n\nThe authentication settings are here rather than read from their own endpoint because that one is behind `manage:system`, while what they decide — whether a passkey may be signed in with, whether a profile may be edited — has to be known to whoever is looking, logged in or not.\n\nCarries the session, so it is never cached.',
tags: ['System'], tags: ['System'],
querystring: { querystring: {
type: 'object', type: 'object',
@ -37,11 +38,12 @@ async function routes(app: FastifyInstance) {
}, },
response: { response: {
200: { 200: {
description: 'Site, flags and session', description: 'Site, flags, authentication settings and session',
type: 'object', type: 'object',
properties: { properties: {
site: { $ref: 'Site#' }, site: { $ref: 'Site#' },
flags: { $ref: 'SystemFlags#' }, flags: { $ref: 'SystemFlags#' },
auth: { $ref: 'AuthConfig#' },
user: { user: {
type: 'object', type: 'object',
description: description:
@ -76,6 +78,7 @@ async function routes(app: FastifyInstance) {
isEnabled: site.isEnabled isEnabled: site.isEnabled
}, },
flags: WIKI.models.flags.getFlags(), flags: WIKI.models.flags.getFlags(),
auth: WIKI.models.authentication.getConfig(),
user: whoAmI(req), user: whoAmI(req),
locales: await WIKI.models.locales.getInstalledLocales() locales: await WIKI.models.locales.getInstalledLocales()
} }

@ -93,6 +93,27 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
} }
}) })
/**
* AUTH CONFIG - The instance-wide authentication settings. Used both ways: as the response, and
* as a partial update body
*/
app.addSchema({
$id: 'AuthConfig',
type: 'object',
properties: {
allowPasskeys: {
type: 'boolean',
description:
'Whether a passkey may be registered or signed in with. Turned off, the passkeys already registered are kept and start working again the moment it is turned back on.'
},
allowProfileEditing: {
type: 'boolean',
description:
'Whether a user may edit their own profile. Off for an instance whose user records are owned by an identity provider.'
}
}
})
/** /**
* AUTH STRATEGY - A configured instance of a module * AUTH STRATEGY - A configured instance of a module
*/ */

@ -96,9 +96,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
comments: { comments: {
type: 'boolean' type: 'boolean'
}, },
profile: {
type: 'boolean'
},
reasonForChange: { reasonForChange: {
type: 'string', type: 'string',
enum: ['off', 'optional', 'required'] enum: ['off', 'optional', 'required']

@ -76,18 +76,26 @@ async function systemUserGuard(req: FastifyRequest, userId: string): Promise<Cus
} }
/** /**
* Whether self-service profile editing is enabled on the site being browsed. * The profile fields an identity provider owns: who the person is, as the wiki displays them.
* `allowProfileEditing` is what says whether they are the user's to change here.
*/
const IDENTITY_PROFILE_FIELDS = ['name', 'location', 'jobTitle', 'pronouns'] as const
/**
* The rest of the profile: how the wiki behaves for this one person.
* *
* It is a per-site feature: an instance whose user data comes from an external identity provider turns * Never gated on `allowProfileEditing`, because no identity provider owns them a time zone, a date
* it off. The site is resolved from the request hostname, which is how the admin flag is scoped; an * format and a colour-vision setting are properties of whoever is reading, not of the account record
* unresolvable hostname leaves the feature at its default. * an administrator is keeping authoritative. Turning profile editing off to keep names in step with
* a directory must not take somebody's accessibility settings away with it.
*/ */
async function isProfileEditable(req: FastifyRequest): Promise<boolean> { const PERSONAL_PROFILE_FIELDS = [
const site = req.hostname 'timezone',
? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }) 'dateFormat',
: null 'timeFormat',
return !site || site.config?.features?.profile !== false 'appearance',
} 'cvd'
] as const
/** /**
* Users API Routes * Users API Routes
@ -273,7 +281,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. Requires the current site to have the `profile` feature enabled. 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 time zone, date and time formats, appearance and colour-vision settings are the users 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#'
@ -302,10 +310,6 @@ async function routes(app: FastifyInstance) {
if (!userId) { if (!userId) {
return reply.unauthorized() return reply.unauthorized()
} }
if (!(await isProfileEditable(req))) {
return reply.forbidden('Profile editing is disabled on this site.')
}
// -> A bad time zone would break every date the user sees, and the list of valid zones is only // -> A bad time zone would break every date the user sees, and the list of valid zones is only
// known at runtime, so it cannot be expressed as a schema enum // known at runtime, so it cannot be expressed as a schema enum
if (req.body.timezone !== undefined && req.body.timezone !== '') { if (req.body.timezone !== undefined && req.body.timezone !== '') {
@ -318,21 +322,19 @@ async function routes(app: FastifyInstance) {
} }
const patch: UserProfilePatch = {} const patch: UserProfilePatch = {}
for (const key of [ for (const key of [...IDENTITY_PROFILE_FIELDS, ...PERSONAL_PROFILE_FIELDS] as const) {
'name',
'location',
'jobTitle',
'pronouns',
'timezone',
'dateFormat',
'timeFormat',
'appearance',
'cvd'
] as const) {
if (req.body[key] !== undefined) { if (req.body[key] !== undefined) {
patch[key] = req.body[key] patch[key] = req.body[key]
} }
} }
if (!WIKI.models.authentication.isProfileEditingAllowed()) {
const refused = IDENTITY_PROFILE_FIELDS.filter((key) => patch[key] !== undefined)
if (refused.length > 0) {
return reply.forbidden(
`Profile editing is disabled on this wiki: ${refused.join(', ')} cannot be changed here.`
)
}
}
if (Object.keys(patch).length < 1) { if (Object.keys(patch).length < 1) {
throw new CustomError('userProfileEmpty', 'No profile fields provided to update.') throw new CustomError('userProfileEmpty', 'No profile fields provided to update.')
} }
@ -379,7 +381,7 @@ async function routes(app: FastifyInstance) {
{ {
schema: { schema: {
summary: "Replace the logged in user's own avatar", summary: "Replace the logged in user's own avatar",
description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${avatarUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type. Resized to a 180x180 JPEG when the Sharp extension is installed, otherwise stored as uploaded. Requires the current site to have the \`profile\` feature enabled.`, description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${avatarUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type. Resized to a 180x180 JPEG when the Sharp extension is installed, otherwise stored as uploaded. Requires profile editing to be enabled on this wiki (Administration → Authentication).`,
tags: ['Users'], tags: ['Users'],
consumes: [...imageMimeTypes], consumes: [...imageMimeTypes],
response: { response: {
@ -403,8 +405,8 @@ async function routes(app: FastifyInstance) {
if (!userId) { if (!userId) {
return reply.unauthorized() return reply.unauthorized()
} }
if (!(await isProfileEditable(req))) { if (!WIKI.models.authentication.isProfileEditingAllowed()) {
return reply.forbidden('Profile editing is disabled on this site.') return reply.forbidden('Profile editing is disabled on this wiki.')
} }
const data = req.body const data = req.body
@ -442,7 +444,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: "Remove the logged in user's own avatar", summary: "Remove the logged in user's own avatar",
description: description:
'Leaves the user to be rendered as a placeholder again. Succeeds even if there was no avatar to remove. Requires the current site to have the `profile` feature enabled.', 'Leaves the user to be rendered as a placeholder again. Succeeds even if there was no avatar to remove. Requires profile editing to be enabled on this wiki (Administration → Authentication).',
tags: ['Users'], tags: ['Users'],
response: { response: {
200: { 200: {
@ -465,8 +467,8 @@ async function routes(app: FastifyInstance) {
if (!userId) { if (!userId) {
return reply.unauthorized() return reply.unauthorized()
} }
if (!(await isProfileEditable(req))) { if (!WIKI.models.authentication.isProfileEditingAllowed()) {
return reply.forbidden('Profile editing is disabled on this site.') return reply.forbidden('Profile editing is disabled on this wiki.')
} }
await WIKI.models.users.clearAvatar(userId) await WIKI.models.users.clearAvatar(userId)
@ -663,6 +665,11 @@ async function routes(app: FastifyInstance) {
type: 'boolean', type: 'boolean',
description: description:
'Whether the account has another way in — a passkey or another linked provider — and may therefore turn password login off.' 'Whether the account has another way in — a passkey or another linked provider — and may therefore turn password login off.'
},
canChangePassword: {
type: 'boolean',
description:
'Whether this strategy lets a user change their own password here. False where an administrator has turned `allowPasswordChange` off, which does not stop a password change the wiki itself demands at sign-in.'
} }
} }
} }
@ -700,7 +707,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: "Change the logged in user's own password", summary: "Change the logged in user's own password",
description: description:
'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here. Also clears any pending forced password change.', 'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here, and only while its `allowPasswordChange` setting is on. Also clears any pending forced password change.',
tags: ['Users'], tags: ['Users'],
body: { body: {
type: 'object', type: 'object',
@ -733,6 +740,17 @@ async function routes(app: FastifyInstance) {
return reply.unauthorized() return reply.unauthorized()
} }
/*
The strategy's own setting, not an instance-wide one: a wiki whose passwords are handed out
by an administrator turns it off, and the strategy is where that is configured. It governs
this route alone a password change the wiki DEMANDS at sign-in (`mustChangePwd`) runs
through the login continuation and is not somebody choosing to change their password.
*/
const strategy = await WIKI.models.authentication.getStrategyById(req.body.strategyId)
if (strategy?.config?.allowPasswordChange === false) {
return reply.forbidden('This authentication strategy does not allow password changes.')
}
try { try {
await WIKI.models.users.changeOwnPassword({ await WIKI.models.users.changeOwnPassword({
userId, userId,
@ -1029,6 +1047,10 @@ async function routes(app: FastifyInstance) {
return reply.unauthorized() return reply.unauthorized()
} }
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try { try {
const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({ const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({
userId, userId,
@ -1092,6 +1114,10 @@ async function routes(app: FastifyInstance) {
return reply.unauthorized() return reply.unauthorized()
} }
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try { try {
const passkey = await WIKI.models.passkeys.finalizeRegistration({ const passkey = await WIKI.models.passkeys.finalizeRegistration({
userId, userId,

@ -106,6 +106,12 @@ defaults:
hideLocal: false hideLocal: false
loginBgUrl: '' loginBgUrl: ''
secret: 'abcdef1234567890abcdef1234567890abcdef' secret: 'abcdef1234567890abcdef1234567890abcdef'
# Whether passkeys may be used at all. Off, a passkey can neither be registered nor signed in
# with — the ones already registered are kept, and work again the moment it is back on.
allowPasskeys: true
# Whether a user may edit their own profile — their name, their location, their avatar. Off
# for an instance whose user records come from an identity provider and are written there.
allowProfileEditing: true
security: security:
corsMode: 'OFF' corsMode: 'OFF'
corsConfig: '' corsConfig: ''

@ -193,6 +193,7 @@
"admin.audit.actions.updateApprovalRule": "Updated an approval rule", "admin.audit.actions.updateApprovalRule": "Updated an approval rule",
"admin.audit.actions.updateAsset": "Renamed or moved a file", "admin.audit.actions.updateAsset": "Renamed or moved a file",
"admin.audit.actions.updateAuditConfig": "Changed the audit log retention", "admin.audit.actions.updateAuditConfig": "Changed the audit log retention",
"admin.audit.actions.updateAuthConfig": "Changed the authentication configuration",
"admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy", "admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy",
"admin.audit.actions.updateAvatar": "Changed their avatar", "admin.audit.actions.updateAvatar": "Changed their avatar",
"admin.audit.actions.updateBlock": "Changed the blocks of a site", "admin.audit.actions.updateBlock": "Changed the blocks of a site",
@ -265,17 +266,23 @@
"admin.auth.activeStrategies": "Active Strategies", "admin.auth.activeStrategies": "Active Strategies",
"admin.auth.addPending": "{strategy} added. It is created when you press Apply.", "admin.auth.addPending": "{strategy} added. It is created when you press Apply.",
"admin.auth.addStrategy": "Add Strategy", "admin.auth.addStrategy": "Add Strategy",
"admin.auth.allowPasskeys": "Allow Passkeys",
"admin.auth.allowPasskeysHint": "Can users sign in with a passkey, and register new ones? Passkeys that are already registered are kept while this is off, and work again as soon as you turn it back on.",
"admin.auth.allowProfileEditing": "Allow Profile Editing",
"admin.auth.allowProfileEditingHint": "Can users edit their own profile? If profile data is managed by an external identity provider, you should turn this off.",
"admin.auth.allowedEmailRegex": "Allowed Email Address Regex", "admin.auth.allowedEmailRegex": "Allowed Email Address Regex",
"admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.", "admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.",
"admin.auth.allowedWebOrigins": "Allowed Web Origins", "admin.auth.allowedWebOrigins": "Allowed Web Origins",
"admin.auth.autoEnrollGroups": "Assign to group(s)", "admin.auth.autoEnrollGroups": "Assign to group(s)",
"admin.auth.autoEnrollGroupsHint": "(optional) Automatically assign new users to these groups. New users are always added to the Users group regardless of this setting.", "admin.auth.autoEnrollGroupsHint": "(optional) Automatically assign new users to these groups. New users are always added to the Users group regardless of this setting.",
"admin.auth.callbackUrl": "Callback URL / Redirect URI", "admin.auth.callbackUrl": "Callback URL / Redirect URI",
"admin.auth.config": "Configuration",
"admin.auth.configHint": "Settings that apply to every site of this instance.",
"admin.auth.configReference": "Configuration Reference", "admin.auth.configReference": "Configuration Reference",
"admin.auth.configReferenceSubtitle": "Some strategies may require some configuration values to be set on your provider. These are provided for reference only and may not be needed by the current strategy.", "admin.auth.configReferenceSubtitle": "Some strategies may require some configuration values to be set on your provider. These are provided for reference only and may not be needed by the current strategy.",
"admin.auth.configSaveFailed": "Failed to save the authentication configuration.",
"admin.auth.deleteConfirm": "Are you sure you want to delete the {strategy} strategy? Users who can only sign in through it will lose access.", "admin.auth.deleteConfirm": "Are you sure you want to delete the {strategy} strategy? Users who can only sign in through it will lose access.",
"admin.auth.deleteFailed": "Failed to delete the strategy.", "admin.auth.deleteFailed": "Failed to delete the strategy.",
"admin.auth.deleteLocalForbidden": "Every account is registered against the local strategy, so it cannot be deleted.",
"admin.auth.deleteStrategy": "Delete Strategy", "admin.auth.deleteStrategy": "Delete Strategy",
"admin.auth.deleteSuccess": "{strategy} has been deleted.", "admin.auth.deleteSuccess": "{strategy} has been deleted.",
"admin.auth.displayName": "Display Name", "admin.auth.displayName": "Display Name",
@ -433,8 +440,6 @@
"admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.", "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.",
"admin.general.allowComments": "Allow Comments", "admin.general.allowComments": "Allow Comments",
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
"admin.general.allowProfile": "Allow Profile Editing",
"admin.general.allowProfileHint": "Can users edit their own profile? If profile data is managed by an external identity provider, you should turn this off.",
"admin.general.allowRatings": "Allow Ratings", "admin.general.allowRatings": "Allow Ratings",
"admin.general.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.", "admin.general.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.",
"admin.general.allowSearch": "Allow Search", "admin.general.allowSearch": "Allow Search",
@ -2477,6 +2482,7 @@
"profile.authInfo": "Your account is associated with the following authentication methods:", "profile.authInfo": "Your account is associated with the following authentication methods:",
"profile.authLoadingFailed": "Failed to load authentication methods.", "profile.authLoadingFailed": "Failed to load authentication methods.",
"profile.authModifyTfa": "Modify 2FA", "profile.authModifyTfa": "Modify 2FA",
"profile.authPasswordChangeDisabled": "Your wiki administrator has disabled password changes for this login method.",
"profile.authPasswordLoginOff": "Password login is turned off for this account.", "profile.authPasswordLoginOff": "Password login is turned off for this account.",
"profile.authPasswordLoginOnlyMethod": "Register a passkey or link another authentication method before turning this off.", "profile.authPasswordLoginOnlyMethod": "Register a passkey or link another authentication method before turning this off.",
"profile.authSetTfa": "Set 2FA", "profile.authSetTfa": "Set 2FA",
@ -2504,7 +2510,7 @@
"profile.dateFormatHint": "Set your preferred format to display dates.", "profile.dateFormatHint": "Set your preferred format to display dates.",
"profile.displayName": "Display Name", "profile.displayName": "Display Name",
"profile.displayNameHint": "Your full name; shown when authoring content (e.g. pages, comments, etc.).", "profile.displayNameHint": "Your full name; shown when authoring content (e.g. pages, comments, etc.).",
"profile.editDisabledDescription": "Your wiki administrator has disabled profile editing.", "profile.editDisabledDescription": "Your wiki administrator has disabled profile editing. Your preferences and accessibility settings below can still be changed.",
"profile.editDisabledTitle": "Profile info is managed by your organization.", "profile.editDisabledTitle": "Profile info is managed by your organization.",
"profile.email": "Email Address", "profile.email": "Email Address",
"profile.emailHint": "The email address used for login.", "profile.emailHint": "The email address used for login.",
@ -2533,6 +2539,7 @@
"profile.passkeysDeactivateConfirm": "Are you sure you want to deactivate this passkey?", "profile.passkeysDeactivateConfirm": "Are you sure you want to deactivate this passkey?",
"profile.passkeysDeactivateFailed": "Failed to deactivate the passkey.", "profile.passkeysDeactivateFailed": "Failed to deactivate the passkey.",
"profile.passkeysDeactivateSuccess": "Passkey deactivated successfully. You may still need to remove the passkey from your device.", "profile.passkeysDeactivateSuccess": "Passkey deactivated successfully. You may still need to remove the passkey from your device.",
"profile.passkeysDisabled": "Passkeys are turned off on this wiki. Any you have registered are kept, and work again if an administrator turns them back on.",
"profile.passkeysIntro": "Passkeys are a replacement for passwords for a faster, easier and more secure login. It relies on your device existing biometrics (phone, computer, security key) to validate your identity.", "profile.passkeysIntro": "Passkeys are a replacement for passwords for a faster, easier and more secure login. It relies on your device existing biometrics (phone, computer, security key) to validate your identity.",
"profile.passkeysInvalidName": "Passkey name is missing or invalid.", "profile.passkeysInvalidName": "Passkey name is missing or invalid.",
"profile.passkeysName": "Passkey Name", "profile.passkeysName": "Passkey Name",

@ -72,6 +72,7 @@ export const AUDIT_ACTIONS = {
'createApprovalRule', 'createApprovalRule',
'updateApprovalRule', 'updateApprovalRule',
'deleteApprovalRule', 'deleteApprovalRule',
'updateAuthConfig',
'createAuthStrategy', 'createAuthStrategy',
'updateAuthStrategy', 'updateAuthStrategy',
'deleteAuthStrategy', 'deleteAuthStrategy',

@ -132,10 +132,82 @@ function isBuiltInLocal(id: string): boolean {
return id === WIKI.data.systemIds.localAuthId return id === WIKI.data.systemIds.localAuthId
} }
/**
* The instance-wide authentication settings, i.e. the half of the `auth` settings blob a person
* sets. The rest of that blob the session secret, the signing keypair, the seeded IDs is the
* installation's own and is never read or written through here.
*/
export const AUTH_CONFIG_FIELDS = ['allowPasskeys', 'allowProfileEditing'] as const
/** /**
* Authentication model * Authentication model
*/ */
class Authentication { class Authentication {
/**
* The instance-wide authentication settings, as the admin area expects them
*/
getConfig(): Record<string, any> {
const auth = WIKI.config.auth ?? {}
const config: Record<string, any> = {}
for (const field of AUTH_CONFIG_FIELDS) {
config[field] = auth[field] !== false
}
return config
}
/**
* Keep only the fields this model owns, dropping anything else a client sends.
*
* Which is what keeps the secrets in the same blob out of reach: `certs` and `secret` are not
* fields of this configuration, so no route that goes through here can be talked into writing one.
*/
pickFields(body: Record<string, any>): Record<string, any> {
const patch: Record<string, any> = {}
for (const field of AUTH_CONFIG_FIELDS) {
if (body[field] !== undefined) {
patch[field] = Boolean(body[field])
}
}
return patch
}
/**
* Save a patch of the instance-wide settings.
*
* @returns Whether the settings were saved
*/
async updateConfig(patch: Record<string, any>): Promise<boolean> {
const previousAuth = WIKI.config.auth
WIKI.config.auth = { ...previousAuth, ...patch }
if (!(await WIKI.configSvc.saveToDb(['auth']))) {
WIKI.config.auth = previousAuth
return false
}
return true
}
/**
* Whether a passkey may be registered or signed in with.
*
* Turned off, the passkeys already registered are left where they are rather than deleted this
* is a setting an administrator can turn back on, and a wiki that forgot every passkey in the
* meantime would have made that a one-way door.
*/
arePasskeysAllowed(): boolean {
return WIKI.config.auth?.allowPasskeys !== false
}
/**
* Whether a user may edit their own profile.
*
* Instance-wide rather than per site: the profile is one record on one account, and an account
* that reaches two sites of an instance cannot have it editable on one of them and not the other.
*/
isProfileEditingAllowed(): boolean {
return WIKI.config.auth?.allowProfileEditing !== false
}
async getStrategy(module: string) { async getStrategy(module: string) {
return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module)) return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module))
} }

@ -70,7 +70,9 @@ class Settings {
secret: crypto.randomBytes(32).toString('hex'), secret: crypto.randomBytes(32).toString('hex'),
rootAdminGroupId: ids.groupAdminId, rootAdminGroupId: ids.groupAdminId,
rootAdminUserId: ids.userAdminId, rootAdminUserId: ids.userAdminId,
guestUserId: ids.userGuestId guestUserId: ids.userGuestId,
allowPasskeys: true,
allowProfileEditing: true
} }
}, },
{ {

@ -118,7 +118,6 @@ class Sites {
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: false, comments: false,
profile: true,
reasonForChange: 'optional', reasonForChange: 'optional',
search: true search: true
}, },
@ -384,7 +383,6 @@ class Sites {
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: false, comments: false,
profile: true,
reasonForChange: 'optional', reasonForChange: 'optional',
search: true search: true
}, },

@ -78,6 +78,8 @@ export interface UserProfileAuthMethod {
isPasswordLoginEnabled: boolean isPasswordLoginEnabled: boolean
/** Whether the account has another way in, and may therefore turn password login off. */ /** Whether the account has another way in, and may therefore turn password login off. */
canDisablePasswordLogin: boolean canDisablePasswordLogin: boolean
/** Whether the strategy lets a user change this password from their own profile. */
canChangePassword: boolean
} }
} }
@ -216,16 +218,19 @@ const maxTfaAttempts = 5
* How many ways into the account remain if the given provider stops working: the other providers * How many ways into the account remain if the given provider stops working: the other providers
* linked to it, plus every registered passkey. * linked to it, plus every registered passkey.
* *
* A provider that is itself restricted does not count it is no way in either. Passkeys are counted * A provider that is itself restricted does not count it is no way in either. Neither is a passkey
* whichever host they were registered against: on a multi-site instance one bound to another site * on an instance where passkeys are turned off, however many the account has registered. The ones
* still leaves the account reachable, which is what this guards against. * that do count are counted whichever host they were registered against: on a multi-site instance one
* bound to another site still leaves the account reachable, which is what this guards against.
*/ */
function countAlternativeLogins(user: any, strategyId: string): number { function countAlternativeLogins(user: any, strategyId: string): number {
const auth = (user.auth ?? {}) as Record<string, any> const auth = (user.auth ?? {}) as Record<string, any>
const otherProviders = Object.entries(auth).filter( const otherProviders = Object.entries(auth).filter(
([id, config]) => id !== strategyId && !config?.restrictLogin ([id, config]) => id !== strategyId && !config?.restrictLogin
).length ).length
const passkeys = ((user.passkeys ?? {}).authenticators ?? []).length const passkeys = WIKI.models.authentication.arePasskeysAllowed()
? ((user.passkeys ?? {}).authenticators ?? []).length
: 0
return otherProviders + passkeys return otherProviders + passkeys
} }
@ -1028,7 +1033,12 @@ class Users {
return [] return []
} }
const strategies = await WIKI.db.select().from(authenticationTable) /*
The completed strategies rather than the raw rows: a prop declared by a module after a strategy
was configured is missing from what is stored, and `allowPasswordChange` like `enforceTfa`
below has to read as the module's default there rather than as absent.
*/
const strategies = await WIKI.models.authentication.getActiveStrategies()
const methods: UserProfileAuthMethod[] = [] const methods: UserProfileAuthMethod[] = []
for (const [strategyId, rawConfig] of Object.entries( for (const [strategyId, rawConfig] of Object.entries(
(user.auth ?? {}) as Record<string, any> (user.auth ?? {}) as Record<string, any>
@ -1048,7 +1058,11 @@ class Users {
config.tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa config.tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa
), ),
isPasswordLoginEnabled: !config.restrictLogin, isPasswordLoginEnabled: !config.restrictLogin,
canDisablePasswordLogin: countAlternativeLogins(user, strategyId) > 0 canDisablePasswordLogin: countAlternativeLogins(user, strategyId) > 0,
// -> `!== false` rather than `=== true`, so a module that declares no such prop at all is
// not read as forbidding something it has no opinion about
canChangePassword:
(strategy?.config as Record<string, any>)?.allowPasswordChange !== false
} }
}) })
} }

@ -21,6 +21,12 @@ props:
hint: Send a verification email with a validation link when somebody registers, and refuse them a login until they follow it. Requires a configured mail server — registration is refused outright without one. hint: Send a verification email with a validation link when somebody registers, and refuse them a login until they follow it. Requires a configured mail server — registration is refused outright without one.
icon: received icon: received
default: true default: true
allowPasswordChange:
type: Boolean
title: Allow Password Change
hint: Users can change their own password from their profile page. Turn this off where passwords are set by an administrator, or where the account's password is managed somewhere else.
icon: password
default: true
allowForgotPassword: allowForgotPassword:
type: Boolean type: Boolean
title: Allow Forgot Password title: Allow Forgot Password

@ -21,6 +21,7 @@ import WDialogHost from '@/components/shared/WDialogHost.vue'
import WLoadingOverlay from '@/components/shared/WLoadingOverlay.vue' import WLoadingOverlay from '@/components/shared/WLoadingOverlay.vue'
import WNotifications from '@/components/shared/WNotifications.vue' import WNotifications from '@/components/shared/WNotifications.vue'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useCommonStore } from './stores/common' import { useCommonStore } from './stores/common'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -48,6 +49,7 @@ const dark = useDark()
// STORES // STORES
const authConfigStore = useAuthConfigStore()
const commonStore = useCommonStore() const commonStore = useCommonStore()
const flagsStore = useFlagsStore() const flagsStore = useFlagsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
@ -213,6 +215,7 @@ async function loadBootstrap() {
siteStore.installedLocales = data.locales ?? [] siteStore.installedLocales = data.locales ?? []
siteStore.applySiteInfo(data.site) siteStore.applySiteInfo(data.site)
flagsStore.apply(data.flags) flagsStore.apply(data.flags)
authConfigStore.apply(data.auth)
userStore.applyProfile(data.user) userStore.applyProfile(data.user)
} catch (err) { } catch (err) {
console.warn(`Could not load the site configuration: ${err.message}`) console.warn(`Could not load the site configuration: ${err.message}`)

@ -550,6 +550,7 @@ import { apiErrorMessage } from '@/helpers/apiError'
import { copyToClipboard } from '@/helpers/clipboard' import { copyToClipboard } from '@/helpers/clipboard'
import { localizeError } from '@/helpers/localization' import { localizeError } from '@/helpers/localization'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -564,6 +565,7 @@ const dark = useDark()
// STORES // STORES
const authConfigStore = useAuthConfigStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -688,8 +690,13 @@ const passwordStrength = computed(() => {
} }
}) })
/*
Both halves have to hold: an instance can turn passkeys off, and a browser that cannot do WebAuthn
cannot answer a challenge. The button is absent rather than disabled either way -- there is nothing
a person could do about either one from this screen.
*/
const canUsePasskeys = computed(() => { const canUsePasskeys = computed(() => {
return browserSupportsWebAuthn() return authConfigStore.allowPasskeys && browserSupportsWebAuthn()
}) })
/** The 2FA secret in groups of four, which is how a 32-character string stays readable to type. */ /** The 2FA secret in groups of four, which is how a 32-character string stays readable to type. */

@ -467,6 +467,8 @@ const controlClasses = computed(() => [
? 'bg-white dark:bg-black/20' ? 'bg-white dark:bg-black/20'
: 'rounded-b-none bg-black/4 dark:bg-white/6', : 'rounded-b-none bg-black/4 dark:bg-white/6',
props.disable || props.disabled ? 'pointer-events-none opacity-60' : '', props.disable || props.disabled ? 'pointer-events-none opacity-60' : '',
// -> Says the field is not the reader's to change; see the stripe rule in `tailwind.css`
props.readonly || props.disable || props.disabled ? 'w-input-control--locked' : '',
/* /*
`relative` for the outline and the label. The margin is the room the floated label needs above the `relative` for the outline and the label. The margin is the room the floated label needs above the
control, and it is matched below so the field's box stays symmetric about the control -- otherwise control, and it is matched below so the field's box stays symmetric about the control -- otherwise

@ -767,6 +767,8 @@ const controlClasses = computed(() => [
isDisabled.value ? 'pointer-events-none opacity-60' : '', isDisabled.value ? 'pointer-events-none opacity-60' : '',
// -> readonly keeps full contrast; only the pointer affordance goes away // -> readonly keeps full contrast; only the pointer affordance goes away
props.readonly ? 'cursor-default' : isDisabled.value ? '' : 'cursor-pointer', props.readonly ? 'cursor-default' : isDisabled.value ? '' : 'cursor-pointer',
// -> Says the field is not the reader's to change; see the stripe rule in `tailwind.css`
props.readonly || isDisabled.value ? 'w-input-control--locked' : '',
// -> Room for the floated label above, matched below so the control stays centred in its row; see // -> Room for the floated label above, matched below so the control stays centred in its row; see
// the fuller note in WInput // the fuller note in WInput
hasFloatingLabel.value ? (showsBottom.value ? 'relative mt-2' : 'relative my-2') : '' hasFloatingLabel.value ? (showsBottom.value ? 'relative mt-2' : 'relative my-2') : ''

@ -1756,10 +1756,24 @@
so growing the host box would leave a 1em glyph centred in a 1.4em one. Scaling what that `em` so growing the host box would leave a 1em glyph centred in a 1.4em one. Scaling what that `em`
measures against grows the drawing. An explicit `width` on the element is in pixels and is measures against grows the drawing. An explicit `width` on the element is in pixels and is
untouched by this, exactly as the inline style above leaves the inlined form alone. untouched by this, exactly as the inline style above leaves the inlined form alone.
Aligned here rather than left alone, which is the other difference: Preflight gives every `svg`
`vertical-align: middle`, so the inlined form sits centred on the line without asking, while the
element is a custom tag Preflight never matches and its own `:host` rule sits it on the baseline
-- a 1.4em box on the baseline rides up over the text it was written into. An outer-tree
declaration beats a shadow tree's `:host` whatever the specificity, so this is all it takes, and
saying it here is what makes the editor's preview and the saved page the same picture.
`inline` is the author asking for the baseline instead, and it still means that: the attribute
nudges the element to the -0.125em Iconify aligns text-height icons by, which is exactly what
`inlineIcons` writes onto the saved `<svg>` for the same attribute.
*/ */
iconify-icon { iconify-icon {
font-size: 1.4em; font-size: 1.4em;
} }
iconify-icon:not([inline]) {
vertical-align: middle;
}
/* Twemoji, which the renderer swaps in for `:shortcodes:` */ /* Twemoji, which the renderer swaps in for `:shortcodes:` */
img.emoji { img.emoji {

@ -465,6 +465,36 @@
--w-input-ring-hover: var(--color-white); --w-input-ring-hover: var(--color-white);
} }
/*
A field that cannot be typed into, hatched.
Nothing else said so. A disabled control has `opacity-60`, which reads as "dimmer" rather than
"locked" and says nothing at all about a READONLY one -- and readonly is the case that matters
here, since a profile whose name comes from an identity provider draws its fields at full
contrast with a live-looking cursor. The hatching is the one state a glance can tell apart from
an empty field.
A background-image over the control's own background-color, so it composes with whichever fill
the variant chose (white, the dark well, `bg-black/4`) instead of replacing it. Hard stops rather
than a soft gradient: the stripe is barely there as it is, and an interpolated edge would blur it
into a flat tint at this alpha. Band and gap are the same 4px, so it reads as one alternating
pattern rather than as lines ruled across a field -- an 8px period, fine enough to show on a 36px
dense field and coarse enough not to moire on a full-height one.
*/
.w-input-control--locked {
--w-input-stripe: rgb(0 0 0 / 0.03);
background-image: repeating-linear-gradient(
-45deg,
var(--w-input-stripe) 0 4px,
transparent 4px 8px
);
}
/* -> Higher alpha than the light-mode value, since white over a dark fill separates less */
body.body--dark .w-input-control--locked {
--w-input-stripe: rgb(255 255 255 / 0.045);
}
/* /*
The picker button on a date or time field, in dark mode. The picker button on a date or time field, in dark mode.

@ -10,7 +10,21 @@
{{ t('admin.auth.subtitle') }} {{ t('admin.auth.subtitle') }}
</div> </div>
</div> </div>
<div class="flex-none"> <div class="flex-none flex items-center">
<w-btn-toggle
class="mr-4"
v-model="state.displayMode"
push
no-caps
:toggle-color="dark.isActive ? `white` : `black`"
:toggle-text-color="dark.isActive ? `black` : `white`"
:text-color="dark.isActive ? `white` : `black`"
:color="dark.isActive ? `dark-1` : `white`"
:options="[
{ label: t('admin.auth.strategies'), value: 'strategies' },
{ label: t('admin.auth.config'), value: 'config' }
]" />
<w-separator class="mr-4" vertical />
<w-btn <w-btn
class="mr-2 acrylic-btn" class="mr-2 acrylic-btn"
icon="la:question-circle" icon="la:question-circle"
@ -41,13 +55,16 @@
</div> </div>
</div> </div>
<w-separator inset /> <w-separator inset />
<!-- ========================================== -->
<!-- STRATEGIES -->
<!-- ========================================== -->
<!-- <!--
The same shape the storage view uses for a list beside what it selects: the list is as wide as The same shape the storage view uses for a list beside what it selects: the list is as wide as
it needs to be and the panel takes what is left, wrapping onto its own row when there is no room it needs to be and the panel takes what is left, wrapping onto its own row when there is no room
for both. A 12-column grid cannot say that -- the list is 350px, not some number of twelfths -- for both. A 12-column grid cannot say that -- the list is 350px, not some number of twelfths --
which is how this ended up with the panel on `col-span-full`, i.e. underneath. which is how this ended up with the panel on `col-span-full`, i.e. underneath.
--> -->
<div class="flex flex-wrap p-4 gap-4"> <div class="flex flex-wrap p-4 gap-4" v-if="state.displayMode === `strategies`">
<div class="flex-none"> <div class="flex-none">
<w-card class="rounded bg-dark"> <w-card class="rounded bg-dark">
<w-list style="min-width: 350px" padding dark> <w-list style="min-width: 350px" padding dark>
@ -119,281 +136,344 @@
</w-menu> </w-menu>
</w-btn> </w-btn>
</div> </div>
<!-- -> `min-w-0`, or a long value inside a field would push the panel wider than the row --> <!--
<div class="min-w-0 flex-1" v-if="state.strategy.id"> `min(480px, 100%)` rather than `min-w-0`, and the same reasoning applies to the settings
<w-card class="pb-2"> column inside: a flex item defaults to `min-width: auto`, i.e. its own min-content, so a long
<w-card-header>{{ t('admin.auth.info') }}</w-card-header> value in a field would push the panel wider than the row -- which is what `min-w-0` was for.
<w-item> But zero is a floor that never stops it shrinking, and `flex-wrap` only wraps once an item
<blueprint-icon icon="information" /> cannot fit at its minimum, so nothing ever wrapped: the panel just went on narrowing until
<w-item-section> the settings were a squeezed strip beside a full-width info column. An explicit length is
<w-item-label>{{ t(`admin.auth.infoName`) }}</w-item-label> also a floor, so it replaces `min-w-0` for the overflow it was preventing, and the `min(...,
<w-item-label caption>{{ t(`admin.auth.infoNameHint`) }}</w-item-label> 100%)` keeps that promise on a screen narrower than the floor itself.
</w-item-section> -->
<w-item-section> <div class="flex-1" style="min-width: min(480px, 100%)" v-if="state.strategy.id">
<w-input <!--
outlined The settings and the infobox beside them, the same shape as the list and this panel
v-model="state.strategy.displayName" above: the infobox is 300px wide and the settings take what is left, and the infobox drops
dense onto its own row once there is no longer room for both.
hide-bottom-space -->
:aria-label="t(`admin.auth.infoName`)" /> <div class="flex flex-wrap gap-4">
</w-item-section> <div class="flex-1" style="min-width: min(420px, 100%)">
</w-item> <w-card class="pb-2">
<w-separator class="my-2" inset /> <w-card-header>{{ t('admin.auth.info') }}</w-card-header>
<w-item tag="label"> <w-item>
<blueprint-icon icon="shutdown" top /> <blueprint-icon icon="information" />
<w-item-section> <w-item-section>
<w-item-label>{{ t(`admin.auth.enabled`) }}</w-item-label> <w-item-label>{{ t(`admin.auth.infoName`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.enabledHint`) }}</w-item-label> <w-item-label caption>{{ t(`admin.auth.infoNameHint`) }}</w-item-label>
<w-item-label class="text-deep-orange" v-if="isBuiltInLocal" caption>{{ </w-item-section>
t(`admin.auth.enabledForced`) <w-item-section>
}}</w-item-label> <w-input
<w-item-label class="text-deep-orange" caption>{{ outlined
t(`admin.auth.enabledSiteHint`) v-model="state.strategy.displayName"
}}</w-item-label> dense
</w-item-section> hide-bottom-space
<w-item-section avatar> :aria-label="t(`admin.auth.infoName`)" />
<w-toggle </w-item-section>
v-model="state.strategy.isEnabled" </w-item>
:disable="isBuiltInLocal" <w-separator class="my-2" inset />
:aria-label="t(`admin.auth.enabled`)" /> <w-item tag="label">
</w-item-section> <blueprint-icon icon="shutdown" top />
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="register" />
<w-item-section>
<w-item-label>{{ t(`admin.auth.registration`) }}</w-item-label>
<w-item-label caption>{{
state.strategy.strategy.key === `local`
? t(`admin.auth.registrationLocalHint`)
: t(`admin.auth.registrationHint`)
}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.strategy.registration"
:aria-label="t(`admin.auth.registration`)" />
</w-item-section>
</w-item>
<template v-if="state.strategy.registration">
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="team" />
<w-item-section>
<w-item-label>{{ t(`admin.auth.autoEnrollGroups`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.autoEnrollGroupsHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-select
outlined
:options="state.groups"
v-model="state.strategy.autoEnrollGroups"
multiple
map-options
emit-value
option-value="id"
option-label="name"
options-dense
dense
hide-bottom-space
:aria-label="t(`admin.users.groups`)"
:loading="state.loadingGroups">
<template #selected>
<div class="text-caption" v-if="state.strategy.autoEnrollGroups?.length > 1">
<i18n-t keypath="admin.users.groupsSelected">
<template #count>
<strong>{{ state.strategy.autoEnrollGroups?.length }}</strong>
</template>
</i18n-t>
</div>
<div
class="text-caption"
v-else-if="state.strategy.autoEnrollGroups?.length === 1">
<i18n-t keypath="admin.users.groupSelected">
<template #group
><strong>{{ selectedGroupName }}</strong></template
>
</i18n-t>
</div>
<span v-else />
</template>
<template #option="{ itemProps, opt, selected, toggleOption }">
<w-item v-bind="itemProps">
<w-item-section side>
<w-checkbox
size="sm"
:model-value="selected"
@update:model-value="toggleOption(opt)" />
</w-item-section>
<w-item-section
><w-item-label>{{ opt.name }}</w-item-label></w-item-section
>
</w-item>
</template>
</w-select>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="private" />
<w-item-section>
<w-item-label>{{ t(`admin.auth.allowedEmailRegex`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.allowedEmailRegexHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
v-model="state.strategy.allowedEmailRegex"
dense
hide-bottom-space
:aria-label="t(`admin.auth.allowedEmailRegex`)"
prefix="/"
suffix="/" />
</w-item-section>
</w-item>
</template>
</w-card>
<!-- ----------------------- -->
<!-- Configuration -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.auth.strategyConfiguration') }}</w-card-header>
<w-card-section>
<w-banner
class="mt-4"
v-if="!state.strategy.config || Object.keys(state.strategy.config).length < 1"
:class="dark.isActive ? `bg-dark-4 text-grey-5` : `bg-grey-2 text-grey-7`">
<em>{{ t('admin.auth.noConfigOption') }}</em>
</w-banner>
</w-card-section>
<template v-for="(cfg, cfgKey, idx) in state.strategy.config">
<template v-if="configIfCheck(cfg.if)">
<w-separator class="my-2" inset v-if="idx > 0" />
<w-item v-if="cfg.type === `boolean`" :tag="cfg.readOnly ? `div` : `label`">
<blueprint-icon :icon="cfg.icon" :hue-rotate="cfg.readOnly ? -45 : 0" />
<w-item-section> <w-item-section>
<w-item-label>{{ cfg.title }}</w-item-label> <w-item-label>{{ t(`admin.auth.enabled`) }}</w-item-label>
<w-item-label :class="cfg.readOnly ? `text-orange` : ``" caption>{{ <w-item-label caption>{{ t(`admin.auth.enabledHint`) }}</w-item-label>
cfg.hint <w-item-label class="text-deep-orange" v-if="isBuiltInLocal" caption>{{
t(`admin.auth.enabledForced`)
}}</w-item-label>
<w-item-label class="text-deep-orange" caption>{{
t(`admin.auth.enabledSiteHint`)
}}</w-item-label> }}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section avatar> <w-item-section avatar>
<w-toggle v-model="cfg.value" :aria-label="cfg.title" :disable="cfg.readOnly" /> <w-toggle
v-model="state.strategy.isEnabled"
:disable="isBuiltInLocal"
:aria-label="t(`admin.auth.enabled`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item v-else> <w-separator class="my-2" inset />
<blueprint-icon :icon="cfg.icon" :hue-rotate="cfg.readOnly ? -45 : 0" /> <w-item tag="label">
<blueprint-icon icon="register" />
<w-item-section> <w-item-section>
<w-item-label>{{ cfg.title }}</w-item-label> <w-item-label>{{ t(`admin.auth.registration`) }}</w-item-label>
<w-item-label :class="cfg.readOnly ? `text-orange` : ``" caption>{{ <w-item-label caption>{{
cfg.hint state.strategy.strategy.key === `local`
? t(`admin.auth.registrationLocalHint`)
: t(`admin.auth.registrationHint`)
}}</w-item-label> }}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section <w-item-section avatar>
:style="cfg.type === `number` ? `flex: 0 0 150px;` : ``" <w-toggle
:class="{ 'col-auto': cfg.enum && cfg.enumDisplay === `buttons` }"> v-model="state.strategy.registration"
<w-btn-toggle :aria-label="t(`admin.auth.registration`)" />
v-if="cfg.enum && cfg.enumDisplay === `buttons`" </w-item-section>
v-model="cfg.value" </w-item>
push <template v-if="state.strategy.registration">
glossy <w-separator class="my-2" inset />
no-caps <w-item>
toggle-color="primary" <blueprint-icon icon="team" />
:options="cfg.enum" <w-item-section>
:disable="cfg.readOnly" /> <w-item-label>{{ t(`admin.auth.autoEnrollGroups`) }}</w-item-label>
<w-select <w-item-label caption>{{ t(`admin.auth.autoEnrollGroupsHint`) }}</w-item-label>
v-else-if="cfg.enum" </w-item-section>
outlined <w-item-section>
v-model="cfg.value" <w-select
:options="cfg.enum" outlined
emit-value :options="state.groups"
map-options v-model="state.strategy.autoEnrollGroups"
dense multiple
options-dense map-options
:aria-label="cfg.title" emit-value
:disable="cfg.readOnly" /> option-value="id"
<!-- -> `no-autofill` on every prop a strategy declares, not only the sensitive option-label="name"
ones: a manager offers to fill whatever LOOKS like a credential, and a options-dense
client ID or an issuer URL beside a secret is exactly that shape. What is dense
typed here is the wiki's credential with an identity provider, never the hide-bottom-space
operator's own. --> :aria-label="t(`admin.users.groups`)"
:loading="state.loadingGroups">
<template #selected>
<div
class="text-caption"
v-if="state.strategy.autoEnrollGroups?.length > 1">
<i18n-t keypath="admin.users.groupsSelected">
<template #count>
<strong>{{ state.strategy.autoEnrollGroups?.length }}</strong>
</template>
</i18n-t>
</div>
<div
class="text-caption"
v-else-if="state.strategy.autoEnrollGroups?.length === 1">
<i18n-t keypath="admin.users.groupSelected">
<template #group
><strong>{{ selectedGroupName }}</strong></template
>
</i18n-t>
</div>
<span v-else />
</template>
<template #option="{ itemProps, opt, selected, toggleOption }">
<w-item v-bind="itemProps">
<w-item-section side>
<w-checkbox
size="sm"
:model-value="selected"
@update:model-value="toggleOption(opt)" />
</w-item-section>
<w-item-section
><w-item-label>{{ opt.name }}</w-item-label></w-item-section
>
</w-item>
</template>
</w-select>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="private" />
<w-item-section>
<w-item-label>{{ t(`admin.auth.allowedEmailRegex`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.allowedEmailRegexHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
v-model="state.strategy.allowedEmailRegex"
dense
hide-bottom-space
:aria-label="t(`admin.auth.allowedEmailRegex`)"
prefix="/"
suffix="/" />
</w-item-section>
</w-item>
</template>
</w-card>
<!-- ----------------------- -->
<!-- Configuration -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.auth.strategyConfiguration') }}</w-card-header>
<w-card-section>
<w-banner
class="mt-4"
v-if="!state.strategy.config || Object.keys(state.strategy.config).length < 1"
:class="dark.isActive ? `bg-dark-4 text-grey-5` : `bg-grey-2 text-grey-7`">
<em>{{ t('admin.auth.noConfigOption') }}</em>
</w-banner>
</w-card-section>
<template v-for="(cfg, cfgKey, idx) in state.strategy.config">
<template v-if="configIfCheck(cfg.if)">
<w-separator class="my-2" inset v-if="idx > 0" />
<w-item v-if="cfg.type === `boolean`" :tag="cfg.readOnly ? `div` : `label`">
<blueprint-icon :icon="cfg.icon" :hue-rotate="cfg.readOnly ? -45 : 0" />
<w-item-section>
<w-item-label>{{ cfg.title }}</w-item-label>
<w-item-label :class="cfg.readOnly ? `text-orange` : ``" caption>{{
cfg.hint
}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="cfg.value"
:aria-label="cfg.title"
:disable="cfg.readOnly" />
</w-item-section>
</w-item>
<w-item v-else>
<blueprint-icon :icon="cfg.icon" :hue-rotate="cfg.readOnly ? -45 : 0" />
<w-item-section>
<w-item-label>{{ cfg.title }}</w-item-label>
<w-item-label :class="cfg.readOnly ? `text-orange` : ``" caption>{{
cfg.hint
}}</w-item-label>
</w-item-section>
<w-item-section
:style="cfg.type === `number` ? `flex: 0 0 150px;` : ``"
:class="{ 'col-auto': cfg.enum && cfg.enumDisplay === `buttons` }">
<w-btn-toggle
v-if="cfg.enum && cfg.enumDisplay === `buttons`"
v-model="cfg.value"
push
glossy
no-caps
toggle-color="primary"
:options="cfg.enum"
:disable="cfg.readOnly" />
<w-select
v-else-if="cfg.enum"
outlined
v-model="cfg.value"
:options="cfg.enum"
emit-value
map-options
dense
options-dense
:aria-label="cfg.title"
:disable="cfg.readOnly" />
<!-- -> `no-autofill` on every prop a strategy declares, not only the sensitive
ones: a manager offers to fill whatever LOOKS like a credential, and a
client ID or an issuer URL beside a secret is exactly that shape. What is
typed here is the wiki's credential with an identity provider, never the
operator's own. -->
<w-input
v-else
outlined
v-model="cfg.value"
dense
no-autofill
:type="inputTypeFor(cfg)"
:aria-label="cfg.title"
:disable="cfg.readOnly"
@focus="(ev) => selectStoredSecret(ev, cfg)" />
</w-item-section>
</w-item>
</template>
</template>
</w-card>
<!-- ----------------------- -->
<!-- References -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4" v-if="strategyRefs.length > 0">
<w-card-header>
{{ t('admin.auth.configReference') }}
<template #hint>{{ t('admin.auth.configReferenceSubtitle') }}</template>
</w-card-header>
<w-item v-for="strRef of strategyRefs" :key="strRef.key">
<blueprint-icon :icon="strRef.icon" :hue-rotate="-45" />
<w-item-section>
<w-item-label>{{ strRef.title }}</w-item-label>
<w-item-label caption>{{ strRef.hint }}</w-item-label>
</w-item-section>
<w-item-section>
<!--
These carry the strategy's ID, which the server assigns so until Apply has created
it there is no URL to register with the provider, and showing one built from the
placeholder ID would be showing the wrong one.
-->
<w-item-label v-if="state.strategy.isNew" caption>
{{ t('admin.auth.refAfterSave') }}
</w-item-label>
<w-input <w-input
v-else v-else
outlined outlined
v-model="cfg.value" v-model="strRef.value"
dense dense
no-autofill :aria-label="strRef.title"
:type="inputTypeFor(cfg)" readonly />
:aria-label="cfg.title"
:disable="cfg.readOnly"
@focus="(ev) => selectStoredSecret(ev, cfg)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
</template> </w-card>
</template> </div>
</w-card> <div class="flex-none" style="width: 300px">
<!-- ----------------------- --> <!-- ----------------------- -->
<!-- References --> <!-- Infobox -->
<!-- ----------------------- --> <!-- ----------------------- -->
<w-card class="pb-2 mt-4" v-if="strategyRefs.length > 0"> <w-card class="rounded">
<w-card-section class="text-center">
<!-- -> The module's own icon, the same one the list on the left draws it with, so
a strategy looks the same wherever this screen shows it -->
<w-icon :name="`img:` + state.strategy.strategy.icon" size="100px" />
<div class="text-subtitle2 mt-2">{{ state.strategy.strategy.title }}</div>
<div class="text-caption mt-2">{{ state.strategy.strategy.description }}</div>
</w-card-section>
</w-card>
<!-- -> Absent rather than disabled on the built-in local strategy: every account's
password is registered against it, so deleting it is not a thing that can be done -->
<w-btn
v-if="!isBuiltInLocal"
class="w-full mt-4 acrylic-btn"
icon="la:trash-alt"
flat
color="negative"
:label="t(`admin.auth.deleteStrategy`)"
@click="confirmDelete" />
<!-- -> An unsaved strategy holds a local `new:` placeholder, not the ID the server
assigns on Apply, so showing it would be showing the wrong one -->
<div
v-if="!state.strategy.isNew"
class="text-caption text-grey mt-4 text-center break-all">
ID: {{ state.strategy.id }}
</div>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- CONFIGURATION -->
<!-- ========================================== -->
<div class="flex flex-wrap p-4 gap-4" v-if="state.displayMode === `config`">
<div class="min-w-0 flex-1">
<w-card class="pb-2">
<w-card-header> <w-card-header>
{{ t('admin.auth.configReference') }} {{ t('admin.auth.config') }}
<template #hint>{{ t('admin.auth.configReferenceSubtitle') }}</template> <template #hint>{{ t('admin.auth.configHint') }}</template>
</w-card-header> </w-card-header>
<w-item v-for="strRef of strategyRefs" :key="strRef.key"> <w-item tag="label">
<blueprint-icon :icon="strRef.icon" :hue-rotate="-45" /> <blueprint-icon class="self-start" icon="fingerprint-scan" />
<w-item-section> <w-item-section>
<w-item-label>{{ strRef.title }}</w-item-label> <w-item-label>{{ t(`admin.auth.allowPasskeys`) }}</w-item-label>
<w-item-label caption>{{ strRef.hint }}</w-item-label> <w-item-label caption>{{ t(`admin.auth.allowPasskeysHint`) }}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.allowPasskeys"
:aria-label="t(`admin.auth.allowPasskeys`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon class="self-start" icon="administrator-male" />
<w-item-section> <w-item-section>
<!-- <w-item-label>{{ t(`admin.auth.allowProfileEditing`) }}</w-item-label>
These carry the strategy's ID, which the server assigns so until Apply has created <w-item-label caption>{{ t(`admin.auth.allowProfileEditingHint`) }}</w-item-label>
it there is no URL to register with the provider, and showing one built from the </w-item-section>
placeholder ID would be showing the wrong one. <w-item-section avatar>
--> <w-toggle
<w-item-label v-if="state.strategy.isNew" caption> v-model="state.config.allowProfileEditing"
{{ t('admin.auth.refAfterSave') }} :aria-label="t(`admin.auth.allowProfileEditing`)" />
</w-item-label>
<w-input
v-else
outlined
v-model="strRef.value"
dense
:aria-label="strRef.title"
readonly />
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-card> </w-card>
<!-- ----------------------- -->
<!-- Infobox -->
<!-- ----------------------- -->
<w-card class="mt-4">
<w-card-section class="text-center">
<!-- -> `mx-auto`: `text-center` on the section does nothing for a block-level image,
which sat against the left edge of every card wider than its 300px cap -->
<img
class="w-full mx-auto object-contain rounded"
:src="state.strategy.strategy.logo"
style="height: 100px; max-width: 300px" />
<div class="text-subtitle2 mt-2">{{ state.strategy.strategy.title }}</div>
<div class="text-caption mt-2">{{ state.strategy.strategy.description }}</div>
</w-card-section>
</w-card>
<div class="flex mt-4">
<div class="text-caption text-grey">ID: {{ state.strategy.id }}</div>
<w-space />
<w-btn
class="acrylic-btn"
icon="la:trash-alt"
flat
color="negative"
:disable="isBuiltInLocal"
:label="t(`admin.auth.deleteStrategy`)"
@click="confirmDelete">
<w-tooltip v-if="isBuiltInLocal">{{ t('admin.auth.deleteLocalForbidden') }}</w-tooltip>
</w-btn>
</div>
</div> </div>
</div> </div>
</w-page> </w-page>
@ -410,6 +490,7 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { confirm } from '@/composables/dialog' import { confirm } from '@/composables/dialog'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
@ -419,6 +500,7 @@ const dark = useDark()
// STORES // STORES
const authConfigStore = useAuthConfigStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -443,12 +525,18 @@ const BUILTIN_LOCAL_STRATEGY_ID = '5a528c4c-0a82-4ad2-96a5-2b23811e6588'
const state = reactive({ const state = reactive({
loading: 0, loading: 0,
loadingGroups: true, loadingGroups: true,
displayMode: 'strategies',
groups: [], groups: [],
strategies: [], strategies: [],
activeStrategies: [], activeStrategies: [],
selectedStrategy: '', selectedStrategy: '',
strategy: { strategy: {
strategy: {} strategy: {}
},
/** The instance-wide settings, which belong to no strategy — the Configuration screen. */
config: {
allowPasskeys: true,
allowProfileEditing: true
} }
}) })
@ -565,12 +653,21 @@ async function load() {
state.loadingGroups = true state.loadingGroups = true
loading.show() loading.show()
try { try {
const [modules, strategies, groups] = await Promise.all([ const [modules, strategies, config, groups] = await Promise.all([
API_CLIENT.get('authentication/modules').json(), API_CLIENT.get('authentication/modules').json(),
API_CLIENT.get('authentication/strategies').json(), API_CLIENT.get('authentication/strategies').json(),
API_CLIENT.get('authentication/config').json(),
API_CLIENT.get('groups').json() API_CLIENT.get('groups').json()
]) ])
state.strategies = modules ?? [] state.strategies = modules ?? []
state.config = { ...state.config, ...config }
/*
The running app holds these from `bootstrap`, which answered before this screen touched them
so without this, an administrator who turns passkeys off goes on being offered a passkey on the
login screen (and their own profile goes on offering to register one) until the next full load.
Here rather than in `save`, so opening the screen also settles a change made from elsewhere.
*/
authConfigStore.apply(state.config)
state.activeStrategies = (strategies ?? []).map((str) => { state.activeStrategies = (strategies ?? []).map((str) => {
const mod = state.strategies.find((m) => m.key === str.module) ?? { const mod = state.strategies.find((m) => m.key === str.module) ?? {
key: str.module, key: str.module,
@ -666,7 +763,27 @@ async function save() {
} }
} }
if (failures.length > 0) { /*
The instance-wide settings go with them, whichever screen is in front: both are edited in this
one page and Apply is the page's own button, so switching tab to check something must not be
what loses the edit made on the other.
*/
let configFailure = null
try {
const resp = await API_CLIENT.put('authentication/config', {
json: {
allowPasskeys: state.config.allowPasskeys,
allowProfileEditing: state.config.allowProfileEditing
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
} catch (err) {
configFailure = apiErrorMessage(err)
}
if (failures.length > 0 || configFailure) {
for (const failure of failures) { for (const failure of failures) {
notify({ notify({
type: 'negative', type: 'negative',
@ -674,6 +791,13 @@ async function save() {
caption: failure.message caption: failure.message
}) })
} }
if (configFailure) {
notify({
type: 'negative',
message: t('admin.auth.configSaveFailed'),
caption: configFailure
})
}
} else { } else {
notify({ notify({
type: 'positive', type: 'positive',

@ -202,19 +202,6 @@
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
</template> </template>
<w-item tag="label">
<blueprint-icon icon="administrator-male" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowProfile`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowProfileHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.profile"
:aria-label="t(`admin.general.allowProfile`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<template v-if="flagsStore.experimental"> <template v-if="flagsStore.experimental">
<w-item> <w-item>
<blueprint-icon icon="star-half-empty" /> <blueprint-icon icon="star-half-empty" />
@ -665,8 +652,7 @@ function defaultConfig() {
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: false, comments: false,
reasonForChange: 'required', reasonForChange: 'required'
profile: false
}, },
discoverable: false, discoverable: false,
defaults: { defaults: {
@ -794,7 +780,6 @@ async function save() {
browse: state.config.features?.browse ?? false, browse: state.config.features?.browse ?? false,
comments: state.config.features?.comments ?? false, comments: state.config.features?.comments ?? false,
ratingsMode: state.config.features?.ratingsMode ?? 'off', ratingsMode: state.config.features?.ratingsMode ?? 'off',
profile: state.config.features?.profile ?? false,
reasonForChange: state.config.features?.reasonForChange ?? 'required', reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false search: state.config.features?.search ?? false
}, },

@ -77,15 +77,25 @@
</w-list> </w-list>
</w-card> </w-card>
</div> </div>
<div class="min-w-0 flex-1" v-if="state.target"> <!--
The floors are what make the wrapping real: a flex item's default `min-width: auto` is its
own min-content, which a long value in a field can make wider than the row, and the `min-w-0`
that used to prevent that let both columns shrink for ever instead -- `flex-wrap` only wraps
an item that cannot fit at its minimum, so the settings became a squeezed strip beside a
full-width infobox rather than ever dropping it below. A length is a floor too, so it does
`min-w-0`'s job, and `min(..., 100%)` keeps it from overflowing a screen narrower than
itself. Same pair on the authentication screen, which is laid out the same way.
-->
<div class="flex-1" style="min-width: min(480px, 100%)" v-if="state.target">
<!-- <!--
The settings and the infobox beside them, the same shape as the list and this panel above: The settings and the infobox beside them, the same shape as the list and this panel above:
the infobox is 300px wide and the settings take what is left, both dropping onto their own the infobox is 300px wide and the settings take what is left, and the infobox drops onto
row when there is no room. A 12-column grid could not say that -- `col-span-12` on the its own row once there is no longer room for both. A 12-column grid could not say that --
settings took a whole row of it, which is what put the infobox underneath. `col-span-12` on the settings took a whole row of it, which is what put the infobox
underneath.
--> -->
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<div class="min-w-0 flex-1"> <div class="flex-1" style="min-width: min(420px, 100%)">
<!-- ----------------------- --> <!-- ----------------------- -->
<!-- Content Types --> <!-- Content Types -->
<!-- ----------------------- --> <!-- ----------------------- -->

@ -24,6 +24,10 @@
class="text-caption text-grey"> class="text-caption text-grey">
{{ t('profile.authPasswordLoginOnlyMethod') }} {{ t('profile.authPasswordLoginOnlyMethod') }}
</div> </div>
<!-- -> Says where the missing menu item went, for the same reason the line above does -->
<div v-if="!auth.config.canChangePassword" class="text-caption text-grey">
{{ t('profile.authPasswordChangeDisabled') }}
</div>
</w-item-section> </w-item-section>
<!-- <!--
One trigger rather than a row of buttons: these are occasional actions on a row that also One trigger rather than a row of buttons: these are occasional actions on a row that also
@ -68,7 +72,12 @@
in the source, so `color="blue-7"` would compile to a class that does not exist. in the source, so `color="blue-7"` would compile to a class that does not exist.
--> -->
<w-list dense padding style="min-width: 240px"> <w-list dense padding style="min-width: 240px">
<w-item clickable @click="changePassword(auth.authId)"> <!-- -> Absent rather than disabled when the strategy forbids it: the reason is
on the row itself, and the server refuses the call either way -->
<w-item
v-if="auth.config.canChangePassword"
clickable
@click="changePassword(auth.authId)">
<w-item-section avatar class="!min-w-0 !pr-2"> <w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:key" class="text-blue-7" /> <w-icon name="la:key" class="text-blue-7" />
</w-item-section> </w-item-section>
@ -143,7 +152,12 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-list> </w-list>
<div class="mt-4"> <!--
Turned off instance-wide, the passkeys above stay listed and stay removable: the setting can
be turned back on, and what is registered then starts working again. What goes is the one
thing that cannot be done while it is off.
-->
<div class="mt-4" v-if="authConfigStore.allowPasskeys">
<w-btn <w-btn
icon="la:plus" icon="la:plus"
unelevated unelevated
@ -151,6 +165,7 @@
color="primary" color="primary"
@click="setupPasskey" /> @click="setupPasskey" />
</div> </div>
<div class="text-body2 text-negative mt-4" v-else>{{ t('profile.passkeysDisabled') }}</div>
</div> </div>
<w-inner-loading :showing="state.loading > 0" /> <w-inner-loading :showing="state.loading > 0" />
@ -173,6 +188,12 @@ import ChangePwdDialog from '@/components/ChangePwdDialog.vue'
import SetupTfaDialog from '@/components/SetupTfaDialog.vue' import SetupTfaDialog from '@/components/SetupTfaDialog.vue'
import PasskeyCreateDialog from '@/components/PasskeyCreateDialog.vue' import PasskeyCreateDialog from '@/components/PasskeyCreateDialog.vue'
import { useAuthConfigStore } from '@/stores/authConfig'
// STORES
const authConfigStore = useAuthConfigStore()
// I18N // I18N
const { t } = useI18n() const { t } = useI18n()

@ -60,12 +60,12 @@ import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { computed, reactive } from 'vue' import { computed, reactive } from 'vue'
import { useSiteStore } from '@/stores/site' import { useAuthConfigStore } from '@/stores/authConfig'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
// STORES // STORES
const siteStore = useSiteStore() const authConfigStore = useAuthConfigStore()
const userStore = useUserStore() const userStore = useUserStore()
// I18N // I18N
@ -88,7 +88,7 @@ const state = reactive({
/** What the upload endpoint accepts. */ /** What the upload endpoint accepts. */
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
const canEdit = computed(() => siteStore.features?.profile) const canEdit = computed(() => authConfigStore.allowProfileEditing)
// METHODS // METHODS

@ -119,8 +119,7 @@
dense dense
options-dense options-dense
hide-bottom-space hide-bottom-space
:aria-label="t(`admin.general.defaultTimezone`)" :aria-label="t(`admin.general.defaultTimezone`)" />
:readonly="!canEdit" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator inset spaced="sm" /> <w-separator inset spaced="sm" />
@ -139,8 +138,7 @@
dense dense
hide-bottom-space hide-bottom-space
:aria-label="t(`admin.general.defaultDateFormat`)" :aria-label="t(`admin.general.defaultDateFormat`)"
:options="dateFormats" :options="dateFormats" />
:readonly="!canEdit" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator inset spaced="sm" /> <w-separator inset spaced="sm" />
@ -158,7 +156,6 @@
no-caps no-caps
toggle-color="primary" toggle-color="primary"
:options="timeFormats" :options="timeFormats"
:disable="!canEdit"
:aria-label="t(`profile.timeFormat`)" /> :aria-label="t(`profile.timeFormat`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -177,7 +174,6 @@
no-caps no-caps
toggle-color="primary" toggle-color="primary"
:options="appearances" :options="appearances"
:disable="!canEdit"
:aria-label="t(`profile.appearance`)" /> :aria-label="t(`profile.appearance`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -196,11 +192,12 @@
no-caps no-caps
toggle-color="primary" toggle-color="primary"
:options="cvdChoices" :options="cvdChoices"
:disable="!canEdit"
:aria-label="t(`profile.cvd`)" /> :aria-label="t(`profile.cvd`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<div v-if="canEdit" class="actions-bar mt-6"> <!-- -> Always: the preferences and accessibility settings below are savable whether or not the
information above is editable -->
<div class="actions-bar mt-6">
<w-btn <w-btn
icon="la:check" icon="la:check"
unelevated unelevated
@ -220,12 +217,12 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { computed, onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site' import { useAuthConfigStore } from '@/stores/authConfig'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
// STORES // STORES
const siteStore = useSiteStore() const authConfigStore = useAuthConfigStore()
const userStore = useUserStore() const userStore = useUserStore()
// I18N // I18N
@ -281,7 +278,7 @@ const cvdChoices = [
] ]
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
const canEdit = computed(() => siteStore.features?.profile) const canEdit = computed(() => authConfigStore.allowProfileEditing)
// METHODS // METHODS
@ -324,13 +321,22 @@ async function save() {
message: t('profile.saving') message: t('profile.saving')
}) })
try { try {
// -> The email is displayed read-only and cannot be changed here, so it is left out entirely /*
The email is displayed read-only and cannot be changed here, so it is left out entirely and
so is everything an identity provider owns while profile editing is off, which the server
refuses rather than ignores. What is left is this person's own settings, which are theirs to
change either way.
*/
const resp = await API_CLIENT.put('users/profile', { const resp = await API_CLIENT.put('users/profile', {
json: { json: {
name: state.config.name, ...(canEdit.value
location: state.config.location, ? {
jobTitle: state.config.jobTitle, name: state.config.name,
pronouns: state.config.pronouns, location: state.config.location,
jobTitle: state.config.jobTitle,
pronouns: state.config.pronouns
}
: {}),
timezone: state.config.timezone, timezone: state.config.timezone,
dateFormat: state.config.dateFormat, dateFormat: state.config.dateFormat,
timeFormat: state.config.timeFormat, timeFormat: state.config.timeFormat,

@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
/**
* The instance-wide authentication settings the admin area's Authentication Configuration
* screen, which is not part of any site.
*
* Separate from `site` because these hold for the whole instance, and from `user` because they say
* what is on offer rather than who is asking: the login panel reads `allowPasskeys` before there is
* a session at all. `bootstrap` hands them over with the site, the flags and the session, so an app
* load gets them without a request of its own.
*
* Both default to what the server defaults them to, so the first paint offers what an untouched
* instance offers rather than briefly hiding it.
*/
export const useAuthConfigStore = defineStore('authConfig', {
state: () => ({
allowPasskeys: true,
allowProfileEditing: true
}),
getters: {},
actions: {
apply(authConfig) {
this.$patch({ ...authConfig })
}
}
})

@ -90,7 +90,6 @@ export const useSiteStore = defineStore('site', {
features: { features: {
browse: false, browse: false,
collaborativeEditing: false, collaborativeEditing: false,
profile: false,
ratingsMode: 'off', ratingsMode: 'off',
reasonForChange: 'required', reasonForChange: 'required',
search: false search: false

Loading…
Cancel
Save