mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: add allow passkeys + allow profile editing as global settings + various fixes
This commit is contained in:
27 files changed
+825
-359
No files matched your search
@@ -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 site’s 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']
|
||||
|
||||
+60
-34
@@ -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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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 IDENTITY_PROFILE_FIELDS = ['name', 'location', 'jobTitle', 'pronouns'] as const
|
||||
|
||||
/**
|
||||
* The rest of the profile: how the wiki behaves for this one person.
|
||||
*
|
||||
* Never gated on `allowProfileEditing`, because no identity provider owns them — a time zone, a date
|
||||
* 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.
|
||||
*/
|
||||
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 user’s 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: ''
|
||||
|
||||
+11
-4
@@ -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
|
||||
},
|
||||
|
||||
+20
-6
@@ -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.
|
||||
|
||||
|
||||
+380
-256
@@ -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>{{ cfg.title }}</w-item-label>
|
||||
<w-item-label :class="cfg.readOnly ? `text-orange` : ``" caption>{{
|
||||
cfg.hint
|
||||
<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="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
|
||||
|
||||
Reference in new issue
Block a user