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

scarlett
NGPixel 1 day 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) => {
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try {
const { authOptions, pending } = await WIKI.models.passkeys.startLogin({
hostname: req.hostname,
@ -1112,6 +1115,11 @@ async function routes(app: FastifyInstance) {
}
},
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 {
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
*/

@ -4,14 +4,15 @@ import type { FastifyInstance } from 'fastify'
/**
* Bootstrap API Route
*
* The three things the app has to know before it can draw anything: which site it is on, which system
* flags are set, and who is asking. Each has an endpoint of its own the admin area reads the flags,
* the login flow asks who is logged in once that has changed but a full load needs all three at
* once, and asking for them one at a time is three round trips before the first pixel.
* The things the app has to know before it can draw anything: which site it is on, which system flags
* are set, how the instance authenticates, and who is asking. Each has an endpoint of its own the
* admin area reads the flags, the login flow asks who is logged in once that has changed but a full
* 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
* memory, and the session carries the user. So what this saves is the round trips, which is the whole
* cost.
* None of them touches the database: the site configurations, the flags, the authentication settings
* and the locale list are in memory, and the session carries the user. So what this saves is the
* round trips, which is the whole cost.
*/
async function routes(app: FastifyInstance) {
app.get<{ Querystring: { hostname?: string } }>(
@ -23,7 +24,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Everything the app needs to start',
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'],
querystring: {
type: 'object',
@ -37,11 +38,12 @@ async function routes(app: FastifyInstance) {
},
response: {
200: {
description: 'Site, flags and session',
description: 'Site, flags, authentication settings and session',
type: 'object',
properties: {
site: { $ref: 'Site#' },
flags: { $ref: 'SystemFlags#' },
auth: { $ref: 'AuthConfig#' },
user: {
type: 'object',
description:
@ -76,6 +78,7 @@ async function routes(app: FastifyInstance) {
isEnabled: site.isEnabled
},
flags: WIKI.models.flags.getFlags(),
auth: WIKI.models.authentication.getConfig(),
user: whoAmI(req),
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
*/

@ -96,9 +96,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
comments: {
type: 'boolean'
},
profile: {
type: 'boolean'
},
reasonForChange: {
type: 'string',
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
* it off. The site is resolved from the request hostname, which is how the admin flag is scoped; an
* unresolvable hostname leaves the feature at its default.
* 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
* 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 site = req.hostname
? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
: null
return !site || site.config?.features?.profile !== false
}
const PERSONAL_PROFILE_FIELDS = [
'timezone',
'dateFormat',
'timeFormat',
'appearance',
'cvd'
] as const
/**
* Users API Routes
@ -273,7 +281,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: "Update the logged in user's own profile",
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'],
body: {
$ref: 'UserProfileUpdate#'
@ -302,10 +310,6 @@ async function routes(app: FastifyInstance) {
if (!userId) {
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
// known at runtime, so it cannot be expressed as a schema enum
if (req.body.timezone !== undefined && req.body.timezone !== '') {
@ -318,21 +322,19 @@ async function routes(app: FastifyInstance) {
}
const patch: UserProfilePatch = {}
for (const key of [
'name',
'location',
'jobTitle',
'pronouns',
'timezone',
'dateFormat',
'timeFormat',
'appearance',
'cvd'
] as const) {
for (const key of [...IDENTITY_PROFILE_FIELDS, ...PERSONAL_PROFILE_FIELDS] as const) {
if (req.body[key] !== undefined) {
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) {
throw new CustomError('userProfileEmpty', 'No profile fields provided to update.')
}
@ -379,7 +381,7 @@ async function routes(app: FastifyInstance) {
{
schema: {
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'],
consumes: [...imageMimeTypes],
response: {
@ -403,8 +405,8 @@ async function routes(app: FastifyInstance) {
if (!userId) {
return reply.unauthorized()
}
if (!(await isProfileEditable(req))) {
return reply.forbidden('Profile editing is disabled on this site.')
if (!WIKI.models.authentication.isProfileEditingAllowed()) {
return reply.forbidden('Profile editing is disabled on this wiki.')
}
const data = req.body
@ -442,7 +444,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: "Remove the logged in user's own avatar",
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'],
response: {
200: {
@ -465,8 +467,8 @@ async function routes(app: FastifyInstance) {
if (!userId) {
return reply.unauthorized()
}
if (!(await isProfileEditable(req))) {
return reply.forbidden('Profile editing is disabled on this site.')
if (!WIKI.models.authentication.isProfileEditingAllowed()) {
return reply.forbidden('Profile editing is disabled on this wiki.')
}
await WIKI.models.users.clearAvatar(userId)
@ -663,6 +665,11 @@ async function routes(app: FastifyInstance) {
type: 'boolean',
description:
'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: {
summary: "Change the logged in user's own password",
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'],
body: {
type: 'object',
@ -733,6 +740,17 @@ async function routes(app: FastifyInstance) {
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 {
await WIKI.models.users.changeOwnPassword({
userId,
@ -1029,6 +1047,10 @@ async function routes(app: FastifyInstance) {
return reply.unauthorized()
}
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try {
const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({
userId,
@ -1092,6 +1114,10 @@ async function routes(app: FastifyInstance) {
return reply.unauthorized()
}
if (!WIKI.models.authentication.arePasskeysAllowed()) {
return reply.forbidden('Passkeys are turned off on this wiki.')
}
try {
const passkey = await WIKI.models.passkeys.finalizeRegistration({
userId,

@ -106,6 +106,12 @@ defaults:
hideLocal: false
loginBgUrl: ''
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:
corsMode: 'OFF'
corsConfig: ''

@ -193,6 +193,7 @@
"admin.audit.actions.updateApprovalRule": "Updated an approval rule",
"admin.audit.actions.updateAsset": "Renamed or moved a file",
"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.updateAvatar": "Changed their avatar",
"admin.audit.actions.updateBlock": "Changed the blocks of a site",
@ -265,17 +266,23 @@
"admin.auth.activeStrategies": "Active Strategies",
"admin.auth.addPending": "{strategy} added. It is created when you press Apply.",
"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.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.",
"admin.auth.allowedWebOrigins": "Allowed Web Origins",
"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.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.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.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.deleteSuccess": "{strategy} has been deleted.",
"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.allowComments": "Allow Comments",
"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.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.",
"admin.general.allowSearch": "Allow Search",
@ -2477,6 +2482,7 @@
"profile.authInfo": "Your account is associated with the following authentication methods:",
"profile.authLoadingFailed": "Failed to load authentication methods.",
"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.authPasswordLoginOnlyMethod": "Register a passkey or link another authentication method before turning this off.",
"profile.authSetTfa": "Set 2FA",
@ -2504,7 +2510,7 @@
"profile.dateFormatHint": "Set your preferred format to display dates.",
"profile.displayName": "Display Name",
"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.email": "Email Address",
"profile.emailHint": "The email address used for login.",
@ -2533,6 +2539,7 @@
"profile.passkeysDeactivateConfirm": "Are you sure you want to deactivate this 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.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.passkeysInvalidName": "Passkey name is missing or invalid.",
"profile.passkeysName": "Passkey Name",

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

@ -132,10 +132,82 @@ function isBuiltInLocal(id: string): boolean {
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
*/
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) {
return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module))
}

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

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

@ -78,6 +78,8 @@ export interface UserProfileAuthMethod {
isPasswordLoginEnabled: boolean
/** Whether the account has another way in, and may therefore turn password login off. */
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
* 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
* 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.
* A provider that is itself restricted does not count it is no way in either. Neither is a passkey
* on an instance where passkeys are turned off, however many the account has registered. The ones
* 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 {
const auth = (user.auth ?? {}) as Record<string, any>
const otherProviders = Object.entries(auth).filter(
([id, config]) => id !== strategyId && !config?.restrictLogin
).length
const passkeys = ((user.passkeys ?? {}).authenticators ?? []).length
const passkeys = WIKI.models.authentication.arePasskeysAllowed()
? ((user.passkeys ?? {}).authenticators ?? []).length
: 0
return otherProviders + passkeys
}
@ -1028,7 +1033,12 @@ class Users {
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[] = []
for (const [strategyId, rawConfig] of Object.entries(
(user.auth ?? {}) as Record<string, any>
@ -1048,7 +1058,11 @@ class Users {
config.tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa
),
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.
icon: received
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:
type: Boolean
title: Allow Forgot Password

@ -21,6 +21,7 @@ import WDialogHost from '@/components/shared/WDialogHost.vue'
import WLoadingOverlay from '@/components/shared/WLoadingOverlay.vue'
import WNotifications from '@/components/shared/WNotifications.vue'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useCommonStore } from './stores/common'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
@ -48,6 +49,7 @@ const dark = useDark()
// STORES
const authConfigStore = useAuthConfigStore()
const commonStore = useCommonStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
@ -213,6 +215,7 @@ async function loadBootstrap() {
siteStore.installedLocales = data.locales ?? []
siteStore.applySiteInfo(data.site)
flagsStore.apply(data.flags)
authConfigStore.apply(data.auth)
userStore.applyProfile(data.user)
} catch (err) {
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 { localizeError } from '@/helpers/localization'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
@ -564,6 +565,7 @@ const dark = useDark()
// STORES
const authConfigStore = useAuthConfigStore()
const siteStore = useSiteStore()
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(() => {
return browserSupportsWebAuthn()
return authConfigStore.allowPasskeys && browserSupportsWebAuthn()
})
/** 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'
: 'rounded-b-none bg-black/4 dark:bg-white/6',
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
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' : '',
// -> readonly keeps full contrast; only the pointer affordance goes away
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
// the fuller note in WInput
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`
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.
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 {
font-size: 1.4em;
}
iconify-icon:not([inline]) {
vertical-align: middle;
}
/* Twemoji, which the renderer swaps in for `:shortcodes:` */
img.emoji {

@ -465,6 +465,36 @@
--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.

@ -10,7 +10,21 @@
{{ t('admin.auth.subtitle') }}
</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
class="mr-2 acrylic-btn"
icon="la:question-circle"
@ -41,13 +55,16 @@
</div>
</div>
<w-separator inset />
<!-- ========================================== -->
<!-- STRATEGIES -->
<!-- ========================================== -->
<!--
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
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.
-->
<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">
<w-card class="rounded bg-dark">
<w-list style="min-width: 350px" padding dark>
@ -119,281 +136,344 @@
</w-menu>
</w-btn>
</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">
<w-card class="pb-2">
<w-card-header>{{ t('admin.auth.info') }}</w-card-header>
<w-item>
<blueprint-icon icon="information" />
<w-item-section>
<w-item-label>{{ t(`admin.auth.infoName`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.infoNameHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
v-model="state.strategy.displayName"
dense
hide-bottom-space
:aria-label="t(`admin.auth.infoName`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="shutdown" top />
<w-item-section>
<w-item-label>{{ t(`admin.auth.enabled`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.enabledHint`) }}</w-item-label>
<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-section>
<w-item-section avatar>
<w-toggle
v-model="state.strategy.isEnabled"
:disable="isBuiltInLocal"
:aria-label="t(`admin.auth.enabled`)" />
</w-item-section>
</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" />
<!--
`min(480px, 100%)` rather than `min-w-0`, and the same reasoning applies to the settings
column inside: a flex item defaults to `min-width: auto`, i.e. its own min-content, so a long
value in a field would push the panel wider than the row -- which is what `min-w-0` was for.
But zero is a floor that never stops it shrinking, and `flex-wrap` only wraps once an item
cannot fit at its minimum, so nothing ever wrapped: the panel just went on narrowing until
the settings were a squeezed strip beside a full-width info column. An explicit length is
also a floor, so it replaces `min-w-0` for the overflow it was preventing, and the `min(...,
100%)` keeps that promise on a screen narrower than the floor itself.
-->
<div class="flex-1" style="min-width: min(480px, 100%)" v-if="state.strategy.id">
<!--
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, and the infobox drops
onto its own row once there is no longer room for both.
-->
<div class="flex flex-wrap gap-4">
<div class="flex-1" style="min-width: min(420px, 100%)">
<w-card class="pb-2">
<w-card-header>{{ t('admin.auth.info') }}</w-card-header>
<w-item>
<blueprint-icon icon="information" />
<w-item-section>
<w-item-label>{{ t(`admin.auth.infoName`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.infoNameHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
v-model="state.strategy.displayName"
dense
hide-bottom-space
:aria-label="t(`admin.auth.infoName`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="shutdown" top />
<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>{{ t(`admin.auth.enabled`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.enabledHint`) }}</w-item-label>
<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-section>
<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>
<w-item v-else>
<blueprint-icon :icon="cfg.icon" :hue-rotate="cfg.readOnly ? -45 : 0" />
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="register" />
<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>{{ 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
: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-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-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
v-else
outlined
v-model="cfg.value"
v-model="strRef.value"
dense
no-autofill
:type="inputTypeFor(cfg)"
:aria-label="cfg.title"
:disable="cfg.readOnly"
@focus="(ev) => selectStoredSecret(ev, cfg)" />
:aria-label="strRef.title"
readonly />
</w-item-section>
</w-item>
</template>
</template>
</w-card>
<!-- ----------------------- -->
<!-- References -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4" v-if="strategyRefs.length > 0">
</w-card>
</div>
<div class="flex-none" style="width: 300px">
<!-- ----------------------- -->
<!-- Infobox -->
<!-- ----------------------- -->
<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>
{{ t('admin.auth.configReference') }}
<template #hint>{{ t('admin.auth.configReferenceSubtitle') }}</template>
{{ t('admin.auth.config') }}
<template #hint>{{ t('admin.auth.configHint') }}</template>
</w-card-header>
<w-item v-for="strRef of strategyRefs" :key="strRef.key">
<blueprint-icon :icon="strRef.icon" :hue-rotate="-45" />
<w-item tag="label">
<blueprint-icon class="self-start" icon="fingerprint-scan" />
<w-item-section>
<w-item-label>{{ strRef.title }}</w-item-label>
<w-item-label caption>{{ strRef.hint }}</w-item-label>
<w-item-label>{{ t(`admin.auth.allowPasskeys`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.allowPasskeysHint`) }}</w-item-label>
</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>
<!--
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
v-else
outlined
v-model="strRef.value"
dense
:aria-label="strRef.title"
readonly />
<w-item-label>{{ t(`admin.auth.allowProfileEditing`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.allowProfileEditingHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.allowProfileEditing"
:aria-label="t(`admin.auth.allowProfileEditing`)" />
</w-item-section>
</w-item>
</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>
</w-page>
@ -410,6 +490,7 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { confirm } from '@/composables/dialog'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
@ -419,6 +500,7 @@ const dark = useDark()
// STORES
const authConfigStore = useAuthConfigStore()
const siteStore = useSiteStore()
// I18N
@ -443,12 +525,18 @@ const BUILTIN_LOCAL_STRATEGY_ID = '5a528c4c-0a82-4ad2-96a5-2b23811e6588'
const state = reactive({
loading: 0,
loadingGroups: true,
displayMode: 'strategies',
groups: [],
strategies: [],
activeStrategies: [],
selectedStrategy: '',
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
loading.show()
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/strategies').json(),
API_CLIENT.get('authentication/config').json(),
API_CLIENT.get('groups').json()
])
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) => {
const mod = state.strategies.find((m) => m.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) {
notify({
type: 'negative',
@ -674,6 +791,13 @@ async function save() {
caption: failure.message
})
}
if (configFailure) {
notify({
type: 'negative',
message: t('admin.auth.configSaveFailed'),
caption: configFailure
})
}
} else {
notify({
type: 'positive',

@ -202,19 +202,6 @@
</w-item>
<w-separator class="my-2" inset />
</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">
<w-item>
<blueprint-icon icon="star-half-empty" />
@ -665,8 +652,7 @@ function defaultConfig() {
ratings: false,
ratingsMode: 'off',
comments: false,
reasonForChange: 'required',
profile: false
reasonForChange: 'required'
},
discoverable: false,
defaults: {
@ -794,7 +780,6 @@ async function save() {
browse: state.config.features?.browse ?? false,
comments: state.config.features?.comments ?? false,
ratingsMode: state.config.features?.ratingsMode ?? 'off',
profile: state.config.features?.profile ?? false,
reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false
},

@ -77,15 +77,25 @@
</w-list>
</w-card>
</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 infobox is 300px wide and the settings take what is left, both dropping onto their own
row when there is no room. A 12-column grid could not say that -- `col-span-12` on the
settings took a whole row of it, which is what put the infobox underneath.
the infobox is 300px wide and the settings take what is left, and the infobox drops onto
its own row once there is no longer room for both. A 12-column grid could not say that --
`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="min-w-0 flex-1">
<div class="flex-1" style="min-width: min(420px, 100%)">
<!-- ----------------------- -->
<!-- Content Types -->
<!-- ----------------------- -->

@ -24,6 +24,10 @@
class="text-caption text-grey">
{{ t('profile.authPasswordLoginOnlyMethod') }}
</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>
<!--
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.
-->
<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-icon name="la:key" class="text-blue-7" />
</w-item-section>
@ -143,7 +152,12 @@
</w-item-section>
</w-item>
</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
icon="la:plus"
unelevated
@ -151,6 +165,7 @@
color="primary"
@click="setupPasskey" />
</div>
<div class="text-body2 text-negative mt-4" v-else>{{ t('profile.passkeysDisabled') }}</div>
</div>
<w-inner-loading :showing="state.loading > 0" />
@ -173,6 +188,12 @@ import ChangePwdDialog from '@/components/ChangePwdDialog.vue'
import SetupTfaDialog from '@/components/SetupTfaDialog.vue'
import PasskeyCreateDialog from '@/components/PasskeyCreateDialog.vue'
import { useAuthConfigStore } from '@/stores/authConfig'
// STORES
const authConfigStore = useAuthConfigStore()
// I18N
const { t } = useI18n()

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

@ -119,8 +119,7 @@
dense
options-dense
hide-bottom-space
:aria-label="t(`admin.general.defaultTimezone`)"
:readonly="!canEdit" />
:aria-label="t(`admin.general.defaultTimezone`)" />
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
@ -139,8 +138,7 @@
dense
hide-bottom-space
:aria-label="t(`admin.general.defaultDateFormat`)"
:options="dateFormats"
:readonly="!canEdit" />
:options="dateFormats" />
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
@ -158,7 +156,6 @@
no-caps
toggle-color="primary"
:options="timeFormats"
:disable="!canEdit"
:aria-label="t(`profile.timeFormat`)" />
</w-item-section>
</w-item>
@ -177,7 +174,6 @@
no-caps
toggle-color="primary"
:options="appearances"
:disable="!canEdit"
:aria-label="t(`profile.appearance`)" />
</w-item-section>
</w-item>
@ -196,11 +192,12 @@
no-caps
toggle-color="primary"
:options="cvdChoices"
:disable="!canEdit"
:aria-label="t(`profile.cvd`)" />
</w-item-section>
</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
icon="la:check"
unelevated
@ -220,12 +217,12 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { computed, onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
import { useAuthConfigStore } from '@/stores/authConfig'
import { useUserStore } from '@/stores/user'
// STORES
const siteStore = useSiteStore()
const authConfigStore = useAuthConfigStore()
const userStore = useUserStore()
// I18N
@ -281,7 +278,7 @@ const cvdChoices = [
]
const timezones = Intl.supportedValuesOf('timeZone')
const canEdit = computed(() => siteStore.features?.profile)
const canEdit = computed(() => authConfigStore.allowProfileEditing)
// METHODS
@ -324,13 +321,22 @@ async function save() {
message: t('profile.saving')
})
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', {
json: {
name: state.config.name,
location: state.config.location,
jobTitle: state.config.jobTitle,
pronouns: state.config.pronouns,
...(canEdit.value
? {
name: state.config.name,
location: state.config.location,
jobTitle: state.config.jobTitle,
pronouns: state.config.pronouns
}
: {}),
timezone: state.config.timezone,
dateFormat: state.config.dateFormat,
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: {
browse: false,
collaborativeEditing: false,
profile: false,
ratingsMode: 'off',
reasonForChange: 'required',
search: false

Loading…
Cancel
Save