mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 14:01:58 -05:00
fix: site login options missing implementations
This commit is contained in:
23 files changed
+443
-145
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { maskSensitiveProps } from '../helpers/common.ts'
|
||||
import { isLocalRedirectPath, maskSensitiveProps, originOf } from '../helpers/common.ts'
|
||||
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
|
||||
import type { AuthRequestTarget, AuthStrategy } from '../models/authentication.ts'
|
||||
import type { AuthLogout, AuthRequestTarget, AuthStrategy } from '../models/authentication.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
/**
|
||||
@@ -188,7 +188,9 @@ async function finishRedirectLogin(
|
||||
{ siteId: flow.siteId, strategy, profile, ip: req.ip },
|
||||
req
|
||||
)
|
||||
return reply.redirect(result.redirect || redirect)
|
||||
// -> The page the reader was sent here from, when there was one; otherwise the login and
|
||||
// first-login settings, which is what `result.redirect` is
|
||||
return reply.redirect(redirect !== '/' ? redirect : result.redirect || '/')
|
||||
} catch (err: any) {
|
||||
WIKI.models.flags.authDebug(
|
||||
`Login through ${strategy.module} strategy ${strategy.id} failed: ${err.message}`
|
||||
@@ -207,9 +209,12 @@ async function finishRedirectLogin(
|
||||
*
|
||||
* A module says where by implementing `logoutUrl()` — the OIDC module does, from the URL its strategy
|
||||
* is configured with, and returns null when there is none. Nothing is guessed for a module that has
|
||||
* no such notion.
|
||||
* no such notion. It is handed the wiki's own destination, which it may ask the provider to return to.
|
||||
*/
|
||||
async function providerLogoutUrl(strategyId: string | undefined): Promise<string | null> {
|
||||
async function providerLogoutUrl(
|
||||
strategyId: string | undefined,
|
||||
logout: AuthLogout
|
||||
): Promise<string | null> {
|
||||
if (!strategyId) {
|
||||
return null
|
||||
}
|
||||
@@ -227,7 +232,7 @@ async function providerLogoutUrl(strategyId: string | undefined): Promise<string
|
||||
destroyed either way.
|
||||
*/
|
||||
const answered = await Promise.race([
|
||||
instance.logoutUrl(),
|
||||
instance.logoutUrl(logout),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), LOGOUT_URL_BUDGET_MS))
|
||||
])
|
||||
return answered || null
|
||||
@@ -1195,9 +1200,9 @@ async function routes(app: FastifyInstance) {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
redirect: {
|
||||
type: 'string',
|
||||
maxLength: 255,
|
||||
maxLength: 2048,
|
||||
description:
|
||||
'Where to send the user once they are logged in. A path on this wiki; anything else is ignored.'
|
||||
'The page the user was on their way to, sent back to once they are logged in in place of the login redirect settings of their groups and the site. A path on this wiki; anything else is ignored.'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1222,7 +1227,7 @@ async function routes(app: FastifyInstance) {
|
||||
nonce: nanoid(32),
|
||||
codeVerifier: nanoid(64),
|
||||
// -> Only a path on this wiki: an open redirect is how a login page is turned into a lure
|
||||
redirect: (req.query.redirect ?? '').startsWith('/') ? req.query.redirect! : '/',
|
||||
redirect: isLocalRedirectPath(req.query.redirect ?? '') ? req.query.redirect! : '/',
|
||||
startedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' })
|
||||
}
|
||||
req.session.authFlow = flow
|
||||
@@ -1447,11 +1452,19 @@ async function routes(app: FastifyInstance) {
|
||||
The provider's own logout comes first when there is one, and takes the group's and the site's
|
||||
redirect with it: those say where a reader should end up, which is a preference, whereas an
|
||||
identity provider still holding a session is the logout not having finished. Where the browser
|
||||
goes after that is the provider's business — it is configured with its own return URL.
|
||||
goes after that is the provider's business, unless the strategy asks it to come back to the
|
||||
wiki's own destination (the OIDC module's Return After Logout).
|
||||
*/
|
||||
const wikiRedirect = await WIKI.models.users.getLogoutRedirect(
|
||||
user?.id ?? null,
|
||||
req.params.siteId
|
||||
)
|
||||
// -> Absolute for the provider, which resolves nothing against this wiki's address
|
||||
const returnTo = isLocalRedirectPath(wikiRedirect)
|
||||
? `${originOf(req)}${wikiRedirect}`
|
||||
: wikiRedirect
|
||||
const redirect =
|
||||
(await providerLogoutUrl(req.session?.strategyId)) ??
|
||||
(await WIKI.models.users.getLogoutRedirect(user?.id ?? null, req.params.siteId))
|
||||
(await providerLogoutUrl(req.session?.strategyId, { returnTo })) ?? wikiRedirect
|
||||
|
||||
if (req.session) {
|
||||
// -> Drops the stored session, so the cookie the browser still holds refers to nothing
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { CustomError, isRedirectTarget } from '../helpers/common.ts'
|
||||
import { elevatedGroupGuard } from '../helpers/userGuards.ts'
|
||||
import { SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
@@ -301,6 +301,18 @@ async function routes(app: FastifyInstance) {
|
||||
return reply.notFound('Group does not exist.')
|
||||
}
|
||||
|
||||
// -> Each is navigated to by a member's browser; see `isRedirectTarget` for why that narrows
|
||||
// them. Empty is how a group defers to the site.
|
||||
for (const key of ['redirectOnLogin', 'redirectOnFirstLogin', 'redirectOnLogout'] as const) {
|
||||
const value = req.body[key]
|
||||
if (value !== undefined && value !== '' && !isRedirectTarget(value)) {
|
||||
throw new CustomError(
|
||||
'groupUpdateInvalidRedirect',
|
||||
`${key} must be a path on this wiki or an http(s) URL.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -> Collect only the fields actually provided
|
||||
const patch: GroupPatch = {}
|
||||
if (req.body.name !== undefined) {
|
||||
|
||||
@@ -37,7 +37,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
redirect: {
|
||||
type: 'string',
|
||||
description: 'Where to send the user once logged in. A path within this wiki, or a URL.'
|
||||
description:
|
||||
"Where to send the user once logged in, when they were not on their way to a page: the first-login or login redirect of the first of their groups that sets one, otherwise the site's. A path within this wiki, or a URL."
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
import { validate as uuidValidate } from 'uuid'
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { CustomError, normalizePastedDestination } from '../helpers/common.ts'
|
||||
import { CustomError, isRedirectTarget, normalizePastedDestination } from '../helpers/common.ts'
|
||||
import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts'
|
||||
import { siteAssetKinds } from '../models/sites.ts'
|
||||
import type { SiteAssetKind } from '../models/sites.ts'
|
||||
@@ -427,6 +427,16 @@ async function routes(app: FastifyInstance) {
|
||||
if (req.body.title !== undefined && !/^[^<>"]+$/.test(req.body.title)) {
|
||||
throw new CustomError('siteUpdateInvalidTitle', 'Invalid Site Title')
|
||||
}
|
||||
// -> Each is navigated to by the browser; see `isRedirectTarget` for why that narrows them
|
||||
for (const key of ['loginRedirect', 'welcomeRedirect', 'logoutRedirect'] as const) {
|
||||
const value = req.body.auth?.[key]
|
||||
if (value !== undefined && value !== '' && !isRedirectTarget(value)) {
|
||||
throw new CustomError(
|
||||
'siteUpdateInvalidRedirect',
|
||||
`${key} must be a path on this wiki or an http(s) URL.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
|
||||
@@ -113,6 +113,45 @@ export function isPageUrl(urlPath: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value handed in as a destination is a path on this wiki, and so safe to redirect to.
|
||||
*
|
||||
* A leading slash is not enough on its own. `//host` is a protocol-relative URL naming another site,
|
||||
* and browsers read a backslash as a slash in that position, so `/\host` is the same thing spelled
|
||||
* differently. They also strip tabs and newlines out of a URL before resolving it, which turns
|
||||
* `/<tab>/host` into the first. No path on the wiki holds a backslash or a control character, so
|
||||
* both are refused anywhere in the value rather than only in second place.
|
||||
*/
|
||||
export function isLocalRedirectPath(value: string): boolean {
|
||||
// oxlint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
return /^\/(?![/\\])/.test(value) && !/[\\\u0000-\u001f\u007f]/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value may be stored as a login, first-login or logout redirect, on a site or a group.
|
||||
*
|
||||
* A path on this wiki, or an absolute `http(s)` URL for one that sends people elsewhere -- and nothing
|
||||
* else, because the browser navigates to it with `location`, where a `javascript:` URL is not an
|
||||
* address but a script run in the wiki's own origin. `write:groups` may set a group's, so this is a
|
||||
* line between that rung and every member of the group, not merely tidiness. Empty is the caller's
|
||||
* to allow: on a group it means the site's value applies.
|
||||
*/
|
||||
export function isRedirectTarget(value: string): boolean {
|
||||
if (isLocalRedirectPath(value)) {
|
||||
return true
|
||||
}
|
||||
// oxlint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
if (/[\s\\\u0000-\u001f\u007f]/.test(value)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(value)
|
||||
return protocol === 'http:' || protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin a URL this server writes into a document is built against.
|
||||
*
|
||||
|
||||
@@ -649,6 +649,7 @@
|
||||
"admin.groups.permissionsUsers": "Users Management",
|
||||
"admin.groups.permissionsWebhooks": "Webhooks Management",
|
||||
"admin.groups.provisioned": "Managed by the directory",
|
||||
"admin.groups.redirectInvalid": "Must be a path on this wiki starting with /, or a full http:// or https:// address.",
|
||||
"admin.groups.redirectOnFirstLogin": "First-time Login Redirect",
|
||||
"admin.groups.redirectOnFirstLoginHint": "Optionally redirect the user to a specific page when he/she login for the first time. Leave empty to use the site-defined value.",
|
||||
"admin.groups.redirectOnLogin": "Redirect on Login",
|
||||
@@ -818,6 +819,7 @@
|
||||
"admin.login.logoutRedirectInvalidChars": "Logout Redirect contains invalid characters.",
|
||||
"admin.login.providers": "Login Providers",
|
||||
"admin.login.providersVisbleWarning": "Note that you can always temporarily show all hidden providers by adding ?all=1 to the url. This is useful to login as local admin while hiding it from normal users.",
|
||||
"admin.login.redirectInvalid": "Must be a path on this wiki starting with /, or a full http:// or https:// address.",
|
||||
"admin.login.saveSuccess": "Login configuration saved successfully.",
|
||||
"admin.login.subtitle": "Configure the login user experience of your wiki site",
|
||||
"admin.login.title": "Login",
|
||||
|
||||
@@ -69,6 +69,18 @@ export interface AuthFlowCallback extends AuthFlow {
|
||||
body?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* What a module is handed to build the URL that ends the session at its provider.
|
||||
*/
|
||||
export interface AuthLogout {
|
||||
/**
|
||||
* Where the wiki sends the browser after a logout when no provider is involved: the Logout Redirect
|
||||
* of the user's groups or of the site, made absolute. A module whose protocol lets it may ask the
|
||||
* provider to send the browser on there once its own session has ended.
|
||||
*/
|
||||
returnTo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Who signed in, as a module reports them.
|
||||
*
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
decodeTreePath,
|
||||
decodeUrlPath,
|
||||
isPageUrl,
|
||||
isRedirectTarget,
|
||||
normalizeFolderPath,
|
||||
normalizeNewPagePath,
|
||||
normalizePagePath,
|
||||
@@ -1229,7 +1230,10 @@ class Import {
|
||||
name,
|
||||
permissions,
|
||||
rules,
|
||||
redirectOnLogin: stringOf(record?.redirectOnLogin),
|
||||
// -> Held to what the groups API accepts, since a member's browser navigates to it
|
||||
redirectOnLogin: isRedirectTarget(stringOf(record?.redirectOnLogin))
|
||||
? stringOf(record?.redirectOnLogin)
|
||||
: '',
|
||||
isSystem: false
|
||||
})
|
||||
.onConflictDoUpdate({ target: groupsTable.id, set: { name, permissions, rules } })
|
||||
|
||||
+37
-25
@@ -1768,34 +1768,46 @@ class Users {
|
||||
}
|
||||
|
||||
// Get user groups
|
||||
user.groups = await WIKI.db.query.users
|
||||
.findFirst({
|
||||
columns: {},
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
with: {
|
||||
groups: {
|
||||
columns: {
|
||||
id: true,
|
||||
permissions: true,
|
||||
redirectOnLogin: true
|
||||
}
|
||||
const account = await WIKI.db.query.users.findFirst({
|
||||
columns: {
|
||||
lastLoginAt: true
|
||||
},
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
with: {
|
||||
groups: {
|
||||
columns: {
|
||||
id: true,
|
||||
permissions: true,
|
||||
redirectOnLogin: true,
|
||||
redirectOnFirstLogin: true
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r: any) => r?.groups || [])
|
||||
|
||||
// Get redirect target
|
||||
let redirect = '/'
|
||||
if (user.groups && user.groups.length > 0) {
|
||||
for (const grp of user.groups as any[]) {
|
||||
if (grp.redirectOnLogin && grp.redirectOnLogin !== '/') {
|
||||
redirect = grp.redirectOnLogin
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
user.groups = account?.groups ?? []
|
||||
|
||||
/*
|
||||
Where the login lands when it was not on its way anywhere: a reader who was sent here from a
|
||||
page they were refused goes back to that page instead, and the client decides between the two.
|
||||
|
||||
A first login is one with no stamp yet. Read fresh rather than off `user`, which each caller
|
||||
fetched its own way, and before the stamp below; a login stopped for 2FA or a password change
|
||||
stamps nothing, so its continuation still counts as the first.
|
||||
|
||||
Each setting is the first group's that sets one, then the site's. A group's `/` reads as unset,
|
||||
not as the root: it is what a 2.x group carries by default and arrives with on import.
|
||||
*/
|
||||
const isFirstLogin = !account?.lastLoginAt
|
||||
const groupRedirect = (user.groups as any[])
|
||||
.map((grp) => (isFirstLogin ? grp.redirectOnFirstLogin : grp.redirectOnLogin))
|
||||
.find((target) => target && target !== '/')
|
||||
const siteAuth = context.siteId
|
||||
? (await WIKI.models.sites.getSiteById({ id: context.siteId }))?.config?.auth
|
||||
: null
|
||||
const redirect =
|
||||
groupRedirect || (isFirstLogin ? siteAuth?.welcomeRedirect : siteAuth?.loginRedirect) || '/'
|
||||
|
||||
// Get auth strategy flags
|
||||
const authStr = user.auth[strategyId] || {}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as client from 'openid-client'
|
||||
import { describeAuthError, missingSettings, strategyDebug } from '../../../helpers/authDebug.ts'
|
||||
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||
import type {
|
||||
AuthFlow,
|
||||
AuthFlowCallback,
|
||||
AuthLogout,
|
||||
ProviderProfile
|
||||
} from '../../../models/authentication.ts'
|
||||
|
||||
/**
|
||||
* OpenID Connect / OAuth2
|
||||
@@ -86,7 +91,9 @@ export default class OidcAuthentication {
|
||||
authorization_endpoint: this.conf.authorizationURL,
|
||||
token_endpoint: this.conf.tokenURL,
|
||||
userinfo_endpoint: this.conf.userInfoURL || undefined,
|
||||
jwks_uri: this.conf.jwksURL
|
||||
jwks_uri: this.conf.jwksURL,
|
||||
// -> So `logoutUrl()` reads it from one place whichever way the provider was described
|
||||
end_session_endpoint: this.conf.logoutURL || undefined
|
||||
},
|
||||
clientId,
|
||||
clientSecret
|
||||
@@ -367,14 +374,24 @@ export default class OidcAuthentication {
|
||||
* that moves it is then followed without an administrator editing anything. Null when the provider
|
||||
* publishes none — plenty do not, and RP-initiated logout is optional in the spec.
|
||||
*
|
||||
* Sent as it stands, with no `id_token_hint` or `post_logout_redirect_uri`: neither is required,
|
||||
* and where a provider asks the person to confirm the sign-out because of it, confirming is not the
|
||||
* failure mode worth adding stored ID tokens to avoid.
|
||||
* Sent bare unless Return After Logout is on, and the provider then decides where the browser goes
|
||||
* — usually a page of its own saying the session has ended. On, it carries
|
||||
* `post_logout_redirect_uri`, the wiki's own Logout Redirect, and the `client_id` the spec asks to
|
||||
* accompany it when there is no `id_token_hint` (which would mean keeping ID tokens). A provider
|
||||
* only honours an address registered for the client, which is why this is off by default: turned
|
||||
* on before the address is registered, it makes the provider refuse the logout with an error page.
|
||||
*/
|
||||
async logoutUrl(): Promise<string | null> {
|
||||
if (this.conf.useDiscovery === false) {
|
||||
return this.conf.logoutURL || null
|
||||
async logoutUrl({ returnTo }: AuthLogout): Promise<string | null> {
|
||||
const config = await this.configuration()
|
||||
const endpoint = config.serverMetadata().end_session_endpoint
|
||||
if (!endpoint) {
|
||||
return null
|
||||
}
|
||||
return (await this.configuration()).serverMetadata().end_session_endpoint ?? null
|
||||
if (this.conf.returnAfterLogout !== true) {
|
||||
return endpoint
|
||||
}
|
||||
strategyDebug(this, `asking the provider to return to ${returnTo} after its logout`)
|
||||
// -> `client_id` is added by the library itself
|
||||
return client.buildEndSessionUrl(config, { post_logout_redirect_uri: returnTo }).toString()
|
||||
}
|
||||
}
|
||||
@@ -82,27 +82,34 @@ props:
|
||||
order: 10
|
||||
if:
|
||||
- { key: 'useDiscovery', eq: false }
|
||||
returnAfterLogout:
|
||||
type: Boolean
|
||||
title: Return After Logout
|
||||
hint: Ask the provider to send the browser back to the wiki once its own session has ended, to the Logout Redirect of the user's group or of the site, rather than leaving them on the provider's signed-out page. Register that address with the provider as a post-logout redirect URI first — the full URL, e.g. https://wiki.example.com/ for a Logout Redirect of `/` — and one for each group's Logout Redirect too. An address it does not have registered is refused, and the provider shows an error instead of logging out.
|
||||
icon: back
|
||||
default: false
|
||||
order: 11
|
||||
scopes:
|
||||
type: String
|
||||
title: Scopes
|
||||
hint: Space-separated. `openid` is required; `email` is what an account is matched on here.
|
||||
icon: rules
|
||||
default: 'openid profile email'
|
||||
order: 11
|
||||
order: 12
|
||||
useAcrValues:
|
||||
type: Boolean
|
||||
title: Use ACR Values
|
||||
hint: Optional - Ask the provider for a particular kind of sign-in — two-factor, a smart card, a specific policy — by naming the authentication context this wiki wants.
|
||||
icon: pin-pad
|
||||
default: false
|
||||
order: 12
|
||||
order: 13
|
||||
acrValues:
|
||||
type: String
|
||||
title: ACR Values
|
||||
hint: Space-separated Authentication Context Class References, most preferred first, as the provider documents them. e.g. `urn:mace:incommon:iap:silver`, or a policy name the provider defines.
|
||||
icon: rules
|
||||
default: ''
|
||||
order: 13
|
||||
order: 14
|
||||
if:
|
||||
- { key: 'useAcrValues', eq: true }
|
||||
requireAcr:
|
||||
@@ -111,7 +118,7 @@ props:
|
||||
hint: Refuse a login whose `acr` claim is not one of the values above. Off, they are only a request — OpenID Connect lets a provider ignore them and still answer with a valid token, so without this the setting expresses a preference rather than a requirement. Turn it on once the provider is known to return the claim, since one that returns none refuses everybody.
|
||||
icon: secure
|
||||
default: false
|
||||
order: 14
|
||||
order: 15
|
||||
if:
|
||||
- { key: 'useAcrValues', eq: true }
|
||||
idClaim:
|
||||
@@ -120,42 +127,42 @@ props:
|
||||
hint: Which claim carries the provider's own identifier for the account. Usually sub or id, which never changes.
|
||||
icon: key
|
||||
default: sub
|
||||
order: 15
|
||||
order: 16
|
||||
emailClaim:
|
||||
type: String
|
||||
title: Email Claim
|
||||
hint: Which claim carries the email address.
|
||||
icon: envelope
|
||||
default: email
|
||||
order: 16
|
||||
order: 17
|
||||
displayNameClaim:
|
||||
type: String
|
||||
title: Display Name Claim
|
||||
hint: Which claim carries the name to show. Falls back to the email address when the claim is absent.
|
||||
icon: person
|
||||
default: name
|
||||
order: 17
|
||||
order: 18
|
||||
pictureClaim:
|
||||
type: String
|
||||
title: Picture Claim
|
||||
hint: Which claim carries the URL of the account's picture, fetched on login and stored as the avatar. Leave empty to let people keep whatever avatar they set here.
|
||||
icon: image
|
||||
default: picture
|
||||
order: 18
|
||||
order: 19
|
||||
mapGroups:
|
||||
type: Boolean
|
||||
title: Map Groups
|
||||
hint: Put the user in the wiki groups a claim names, on every login. Only groups that already exist here are matched, by name and ignoring case — nothing is created.
|
||||
icon: user-groups
|
||||
default: false
|
||||
order: 19
|
||||
order: 20
|
||||
groupsClaim:
|
||||
type: String
|
||||
title: Groups Claim
|
||||
hint: Which claim carries the group names. Either one name or a list of them.
|
||||
icon: rules
|
||||
default: groups
|
||||
order: 20
|
||||
order: 21
|
||||
if:
|
||||
- { key: 'mapGroups', eq: true }
|
||||
unassignMissingGroups:
|
||||
@@ -164,7 +171,7 @@ props:
|
||||
hint: Off adds what the claim names and takes nothing away, so a membership granted here survives. On makes the provider the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
|
||||
icon: unfriend
|
||||
default: false
|
||||
order: 21
|
||||
order: 22
|
||||
if:
|
||||
- { key: 'mapGroups', eq: true }
|
||||
refs:
|
||||
|
||||
Generated
-5
@@ -30,7 +30,6 @@
|
||||
"fuse.js": "7.5.0",
|
||||
"highlight.js": "11.12.0",
|
||||
"iconify-icon": "3.0.3",
|
||||
"js-cookie": "3.0.8",
|
||||
"ky": "2.1.0",
|
||||
"markdown-it": "15.0.2",
|
||||
"markdown-it-abbr": "2.0.0",
|
||||
@@ -5427,10 +5426,6 @@
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.8",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"fuse.js": "7.5.0",
|
||||
"highlight.js": "11.12.0",
|
||||
"iconify-icon": "3.0.3",
|
||||
"js-cookie": "3.0.8",
|
||||
"ky": "2.1.0",
|
||||
"markdown-it": "15.0.2",
|
||||
"markdown-it-abbr": "2.0.0",
|
||||
|
||||
+63
-12
@@ -13,6 +13,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { setCssVar } from '@/helpers/cssVars'
|
||||
import { loginLocation, redirectTargetOr } from '@/helpers/loginRedirect'
|
||||
import { isPagePath, splitLocalePath, stripPageExtension } from '@/helpers/pagePaths'
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { notify } from '@/composables/notify'
|
||||
@@ -70,6 +71,9 @@ const state = reactive({
|
||||
isInitialized: false
|
||||
})
|
||||
|
||||
/** Set by a logout on its way out, for the document it lands on to report. */
|
||||
const LOGOUT_NOTICE_KEY = 'wiki.logoutNotice'
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(
|
||||
@@ -321,6 +325,30 @@ router.beforeEach(async (to, from) => {
|
||||
await loadBootstrap()
|
||||
}
|
||||
|
||||
/*
|
||||
-> Bypass Unauthorized Screen
|
||||
A site can skip the unauthorized screen for a visitor who is not logged in, sending them to sign
|
||||
in instead -- for a wiki closed to the public that screen is a dead end with a login button on it.
|
||||
Here rather than in the error page, so the screen is never drawn on the way past it. Below the
|
||||
bootstrap above, since that is where both the setting and the session come from.
|
||||
|
||||
Only when nobody is logged in: somebody who IS signed in and still refused has nothing to gain
|
||||
from the login screen, and gets the error page as usual.
|
||||
|
||||
Where they were refused is what the login sends them back to afterwards, in place of the site's
|
||||
and the groups' login redirects -- see `helpers/loginRedirect.js`.
|
||||
*/
|
||||
if (
|
||||
to.path === '/_error/unauthorized' &&
|
||||
siteStore.auth.bypassUnauthorized &&
|
||||
!userStore.authenticated
|
||||
) {
|
||||
// -> Only a page the reader actually reached: a direct load of the error URL has none
|
||||
const refused =
|
||||
from.matched.length > 0 && !from.path.startsWith('/_error') ? from.fullPath : null
|
||||
return { ...loginLocation(refused), replace: true }
|
||||
}
|
||||
|
||||
/*
|
||||
-> Page extensions
|
||||
A path ending in one of the extensions the site's content is written in addresses the page
|
||||
@@ -397,25 +425,47 @@ router.beforeEach(async (to, from) => {
|
||||
|
||||
// GLOBAL EVENTS HANDLERS
|
||||
|
||||
/*
|
||||
Where a logout goes: the provider's own logout when the strategy has one, otherwise the first group's
|
||||
logout redirect, otherwise the site's -- the server's answer, see the logout route.
|
||||
|
||||
Always a full load, never a router push. Everything the app holds was fetched as the user who just
|
||||
left: the page on screen, which a guest may not be allowed to read, and every store that caches what
|
||||
this session may do. A push to the path already showing is no navigation at all, which is what left
|
||||
a reader logging out from the home page looking at the same page, still drawn for an account.
|
||||
*/
|
||||
EVENT_BUS.on('logout', ({ redirect } = {}) => {
|
||||
const target = redirect || '/'
|
||||
// -> A group or the site can send logged out users to another site entirely, which the router cannot
|
||||
// navigate to — and leaving the wiki means there is no point notifying anyone either
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) {
|
||||
window.location.assign(target)
|
||||
return
|
||||
const target = redirectTargetOr(redirect)
|
||||
// -> Said once the next document has booted, since this one is about to be discarded. Not for a
|
||||
// target that leaves the wiki, where there is nobody to say it to; a lost notice is harmless
|
||||
if (target.startsWith('/')) {
|
||||
try {
|
||||
sessionStorage.setItem(LOGOUT_NOTICE_KEY, '1')
|
||||
} catch {}
|
||||
}
|
||||
router.push(target)
|
||||
notify({
|
||||
type: 'positive',
|
||||
icon: 'mdi:logout',
|
||||
message: i18n.t('auth.logoutSuccess')
|
||||
})
|
||||
window.location.assign(target)
|
||||
})
|
||||
|
||||
EVENT_BUS.on('applyTheme', () => {
|
||||
applyTheme()
|
||||
})
|
||||
|
||||
/** Report a logout the previous document made, if it made one. */
|
||||
function showLogoutNotice() {
|
||||
let pending = false
|
||||
try {
|
||||
pending = sessionStorage.getItem(LOGOUT_NOTICE_KEY) === '1'
|
||||
sessionStorage.removeItem(LOGOUT_NOTICE_KEY)
|
||||
} catch {}
|
||||
if (pending) {
|
||||
notify({
|
||||
type: 'positive',
|
||||
icon: 'mdi:logout',
|
||||
message: i18n.t('auth.logoutSuccess')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LOADER
|
||||
|
||||
router.afterEach(() => {
|
||||
@@ -423,6 +473,7 @@ router.afterEach(() => {
|
||||
state.isInitialized = true
|
||||
applyTheme()
|
||||
document.querySelector('.init-loading').remove()
|
||||
showLogoutNotice()
|
||||
}
|
||||
commonStore.routerLoading = false
|
||||
})
|
||||
|
||||
@@ -25,15 +25,26 @@
|
||||
the pointer is a field that cannot be clicked, which is what made focusing the email address
|
||||
take two tries.
|
||||
-->
|
||||
<div v-if="!state.strategiesLoaded" class="flex justify-center py-8">
|
||||
<!-- -> Kept up while leaving for the provider too, so the form is not painted for the moment
|
||||
before the browser navigates away -->
|
||||
<div
|
||||
v-if="!state.strategiesLoaded || state.isAutoLoginRedirecting"
|
||||
class="flex justify-center py-8">
|
||||
<w-spinner color="primary" size="lg" />
|
||||
</div>
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<!-- LOGIN SCREEN -->
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<template v-else-if="state.screen === `login`">
|
||||
<p class="auth-subtitle" v-if="formStrategies.length < 2">{{ t('auth.enterCredentials') }}</p>
|
||||
<template v-else>
|
||||
<p class="auth-subtitle" v-if="formStrategies.length === 1">
|
||||
{{ t('auth.enterCredentials') }}
|
||||
</p>
|
||||
<p
|
||||
class="auth-subtitle"
|
||||
v-else-if="formStrategies.length === 0 && redirectStrategies.length > 0">
|
||||
{{ t('auth.selectAuthProvider') }}
|
||||
</p>
|
||||
<template v-else-if="formStrategies.length > 1">
|
||||
<p class="auth-subtitle">{{ t('auth.selectAuthProvider') }}</p>
|
||||
<div class="auth-strategies mb-4">
|
||||
<w-btn
|
||||
@@ -55,7 +66,9 @@
|
||||
@click="selectFormStrategy(str.id)" />
|
||||
</div>
|
||||
</template>
|
||||
<w-form ref="loginForm" @submit="login">
|
||||
<!-- -> Only for a strategy that takes a password here: with local authentication off and nothing
|
||||
else of that kind enabled, a username and password would have nowhere to go -->
|
||||
<w-form v-if="formStrategies.length > 0" ref="loginForm" @submit="login">
|
||||
<!--
|
||||
`username`, whatever the strategy calls this field. It is the account identifier of a login
|
||||
form, which is what that token means — an address typed here is not an email address being
|
||||
@@ -111,7 +124,7 @@
|
||||
would only be a step in the way.
|
||||
-->
|
||||
<template v-if="canUsePasskeys">
|
||||
<w-separator class="my-4" />
|
||||
<w-separator v-if="formStrategies.length > 0" class="my-4" />
|
||||
<w-btn
|
||||
class="acrylic-btn w-full"
|
||||
flat
|
||||
@@ -128,7 +141,7 @@
|
||||
over, and it comes back at the callback route with a session already established.
|
||||
-->
|
||||
<template v-if="redirectStrategies.length > 0">
|
||||
<w-separator class="my-4" />
|
||||
<w-separator v-if="formStrategies.length > 0 || canUsePasskeys" class="my-4" />
|
||||
<w-btn
|
||||
class="acrylic-btn w-full mb-2"
|
||||
v-for="str of redirectStrategies"
|
||||
@@ -549,13 +562,13 @@ import { useDark } from '@/composables/dark'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { copyToClipboard } from '@/helpers/clipboard'
|
||||
import { localizeError } from '@/helpers/localization'
|
||||
import { loginDestination, redirectTargetOr } from '@/helpers/loginRedirect'
|
||||
|
||||
import { useAuthConfigStore } from '@/stores/authConfig'
|
||||
import { useCommonStore } from '@/stores/common'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import Cookies from 'js-cookie'
|
||||
import zxcvbn from 'zxcvbn'
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'
|
||||
import VOtpInput from 'vue3-otp-input'
|
||||
@@ -620,6 +633,8 @@ const state = reactive({
|
||||
which is what eventually locks the address out for a quarter of an hour.
|
||||
*/
|
||||
isSubmitting: false,
|
||||
/** Whether the site's Bypass Login Screen has already sent the browser on to the provider. */
|
||||
isAutoLoginRedirecting: false,
|
||||
isTFAShown: false,
|
||||
isTFASetupShown: false,
|
||||
tfaQRImage: '',
|
||||
@@ -776,7 +791,8 @@ function switchTo(screen) {
|
||||
case 'login': {
|
||||
state.screen = 'login'
|
||||
nextTick(() => {
|
||||
loginEmailIpt.value.focus()
|
||||
// -> Absent on a site whose every strategy signs in elsewhere
|
||||
loginEmailIpt.value?.focus()
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -870,14 +886,14 @@ async function fetchStrategies(showAll = false) {
|
||||
function authorizeUrl(str) {
|
||||
const params = new URLSearchParams({ siteId: siteStore.id })
|
||||
/*
|
||||
The same cookie a form login reads on its way out: whatever sent the reader to the login screen
|
||||
left where they were going in it. The provider flow cannot come back through the code above — it
|
||||
lands on the callback route, which redirects — so the destination travels with the request and is
|
||||
handed back by the callback instead.
|
||||
The page the reader was sent here from, if any. The provider flow cannot come back through the
|
||||
code below -- it lands on the callback route, which redirects -- so the destination travels with
|
||||
the request and is handed back by the callback instead. Without one, the server falls back to the
|
||||
login redirect settings.
|
||||
*/
|
||||
const loginRedirect = Cookies.get('loginRedirect')
|
||||
if (loginRedirect) {
|
||||
params.set('redirect', loginRedirect)
|
||||
const destination = loginDestination()
|
||||
if (destination) {
|
||||
params.set('redirect', destination)
|
||||
}
|
||||
return `/_api/auth/${str.id}/authorize?${params.toString()}`
|
||||
}
|
||||
@@ -933,19 +949,12 @@ async function handleLoginResponse(resp) {
|
||||
message: t('auth.loginSuccess'),
|
||||
delay: 0
|
||||
})
|
||||
/*
|
||||
The page the reader was sent here from wins; a login somebody chose to make goes where the
|
||||
server says, which is the first-login or login redirect of their groups or the site.
|
||||
*/
|
||||
setTimeout(() => {
|
||||
const loginRedirect = Cookies.get('loginRedirect')
|
||||
if (loginRedirect === '/' && resp.redirect) {
|
||||
Cookies.remove('loginRedirect')
|
||||
window.location.replace(resp.redirect)
|
||||
} else if (loginRedirect) {
|
||||
Cookies.remove('loginRedirect')
|
||||
window.location.replace(loginRedirect)
|
||||
} else if (resp.redirect) {
|
||||
window.location.replace(resp.redirect)
|
||||
} else {
|
||||
window.location.replace('/')
|
||||
}
|
||||
window.location.replace(loginDestination() ?? redirectTargetOr(resp.redirect))
|
||||
}, 1000)
|
||||
break
|
||||
}
|
||||
@@ -1416,9 +1425,50 @@ onMounted(async () => {
|
||||
*/
|
||||
screenFromQuery()
|
||||
reportRedirectLoginError()
|
||||
await fetchStrategies()
|
||||
await fetchStrategies(showsAllStrategies())
|
||||
autoLogin()
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the login screen was opened with `?all=1`, which lists the providers the site hides as well
|
||||
* as the ones it shows, and stays put rather than bypassing the screen. It is how an administrator
|
||||
* reaches the local login on a site that hides it behind a single sign-on provider, as the admin
|
||||
* area's Login screen tells them.
|
||||
*/
|
||||
function showsAllStrategies() {
|
||||
return new URLSearchParams(window.location.search).get('all') === '1'
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's Bypass Login Screen: go straight to the first strategy's provider rather than showing a
|
||||
* screen whose only useful content is its button.
|
||||
*
|
||||
* Only where that strategy signs in elsewhere -- a form strategy needs this screen to type into. And
|
||||
* only on the plain login screen with nothing to report: a reset or confirmation link came here for
|
||||
* its own screen, and a provider login that just failed would otherwise be sent straight back to the
|
||||
* provider to fail again, in a loop the reader never gets to see the reason for. And never with
|
||||
* `?all=1`, which is an administrator asking for the screen this would skip.
|
||||
*
|
||||
* `replace` rather than a navigation, so Back from the provider does not land here and bounce the
|
||||
* reader forward again.
|
||||
*/
|
||||
function autoLogin() {
|
||||
if (
|
||||
!siteStore.auth.autoLogin ||
|
||||
state.screen !== 'login' ||
|
||||
state.errorMessage ||
|
||||
showsAllStrategies()
|
||||
) {
|
||||
return
|
||||
}
|
||||
const first = state.strategies[0]
|
||||
if (first?.activeStrategy?.strategy?.useForm !== false) {
|
||||
return
|
||||
}
|
||||
state.isAutoLoginRedirecting = true
|
||||
window.location.replace(authorizeUrl(first))
|
||||
}
|
||||
|
||||
/**
|
||||
* Say what went wrong on a login that happened somewhere else.
|
||||
*
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
outlined
|
||||
v-model="state.group.redirectOnLogin"
|
||||
dense
|
||||
:rules="redirectRules"
|
||||
hide-bottom-space
|
||||
:disable="!canManage"
|
||||
:aria-label="t(`admin.groups.redirectOnLogin`)" />
|
||||
</w-item-section>
|
||||
@@ -118,6 +120,8 @@
|
||||
outlined
|
||||
v-model="state.group.redirectOnFirstLogin"
|
||||
dense
|
||||
:rules="redirectRules"
|
||||
hide-bottom-space
|
||||
:disable="!canManage"
|
||||
:aria-label="t(`admin.groups.redirectOnLogin`)" />
|
||||
</w-item-section>
|
||||
@@ -136,6 +140,8 @@
|
||||
outlined
|
||||
v-model="state.group.redirectOnLogout"
|
||||
dense
|
||||
:rules="redirectRules"
|
||||
hide-bottom-space
|
||||
:disable="!canManage"
|
||||
:aria-label="t(`admin.groups.redirectOnLogout`)" />
|
||||
</w-item-section>
|
||||
@@ -755,6 +761,7 @@ import RuleModeMenu from '@/components/RuleModeMenu.vue'
|
||||
import RulePermissionsDialog from '@/components/RulePermissionsDialog.vue'
|
||||
import UserSearchDialog from '@/components/UserSearchDialog.vue'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { isRedirectTarget } from '@/helpers/loginRedirect'
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
@@ -1169,6 +1176,9 @@ const groupNameValidation = [(val) => /^[^<>"]+$/.test(val) || t('admin.groups.n
|
||||
*/
|
||||
const canManage = computed(() => userStore.can('manage:groups') || userStore.can('write:groups'))
|
||||
|
||||
/** The three redirect fields: empty defers to the site, anything else must be somewhere to go. */
|
||||
const redirectRules = [(val) => !val || isRedirectTarget(val) || t('admin.groups.redirectInvalid')]
|
||||
|
||||
/*
|
||||
Whether this user may rewrite what the group is ALLOWED to do, which is the Permissions tab and
|
||||
nothing else. `write:groups` builds and arranges groups -- names, page rules, redirects, who is in
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Where a login goes when it was on its way somewhere.
|
||||
*
|
||||
* A reader sent to the login screen because the page they asked for needs an account is sent back to
|
||||
* that page afterwards. The destination rides in the login URL as `?redirect=`, which is what keeps it
|
||||
* apart from a login somebody chose to make -- the Login button in the header goes to a plain
|
||||
* `/login`, and the site's Login Redirect and First-time Login Redirect settings (or a group's) decide
|
||||
* where that one lands. Those are the server's answer, `redirect` on a login response.
|
||||
*
|
||||
* The same parameter is what the server hands back after a provider login that failed, so a second
|
||||
* attempt still lands where the first was going.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a value is a path on this wiki and so safe to send the browser to. Mirrors
|
||||
* `isLocalRedirectPath` in the backend's `helpers/common.ts`: the destination comes off a URL anybody
|
||||
* can write, and a login page that forwards to wherever it is told is a lure.
|
||||
*/
|
||||
export function isLocalRedirectPath(value) {
|
||||
// oxlint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
return /^\/(?![/\\])/.test(value) && !/[\\\u0000-\u001f\u007f]/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is something a login or logout redirect setting may hold: a path on this wiki or an
|
||||
* absolute `http(s)` URL. Mirrors `isRedirectTarget` in the backend's `helpers/common.ts`, which
|
||||
* refuses anything else on save. Checked here too because the browser navigates to it with
|
||||
* `location`, where a `javascript:` URL runs as script -- and a value stored before the server
|
||||
* checked, or by a 2.x import, never passed that test.
|
||||
*/
|
||||
export function isRedirectTarget(value) {
|
||||
if (isLocalRedirectPath(value)) {
|
||||
return true
|
||||
}
|
||||
// oxlint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
if (/[\s\\\u0000-\u001f\u007f]/.test(value)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(value)
|
||||
return protocol === 'http:' || protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** A redirect the server answered with, or the site root where it is missing or not navigable. */
|
||||
export function redirectTargetOr(value) {
|
||||
return value && isRedirectTarget(value) ? value : '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* The login route for a reader on their way to `destination`.
|
||||
*
|
||||
* The site root carries nothing: it is where a login lands by default anyway, and saying so would
|
||||
* override the settings that exist to send it somewhere better.
|
||||
*/
|
||||
export function loginLocation(destination) {
|
||||
return isDestination(destination)
|
||||
? { path: '/login', query: { redirect: destination } }
|
||||
: { path: '/login' }
|
||||
}
|
||||
|
||||
/** The destination the current login screen was given, or null when it was opened on its own. */
|
||||
export function loginDestination() {
|
||||
const destination = new URLSearchParams(window.location.search).get('redirect')
|
||||
return isDestination(destination) ? destination : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A path worth going back to: on this wiki, not the root (see `loginLocation`), and not the login
|
||||
* screen itself, which would only nest one destination inside another.
|
||||
*/
|
||||
function isDestination(value) {
|
||||
return isLocalRedirectPath(value ?? '') && value !== '/' && !/^\/login(?:[/?#]|$)/.test(value)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { loginLocation } from '@/helpers/loginRedirect'
|
||||
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
@@ -115,7 +116,7 @@ watch(
|
||||
() => route.path,
|
||||
(newValue) => {
|
||||
if (newValue.startsWith('/_inbox') && !userStore.authenticated) {
|
||||
router.replace('/login')
|
||||
router.replace(loginLocation(route.fullPath))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -79,6 +79,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { useMinWidth } from '@/composables/screen'
|
||||
import { loginLocation } from '@/helpers/loginRedirect'
|
||||
|
||||
import { useFlagsStore } from '@/stores/flags'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
@@ -206,7 +207,7 @@ watch(
|
||||
return
|
||||
}
|
||||
if (!userStore.authenticated) {
|
||||
router.replace('/login')
|
||||
router.replace(loginLocation(route.fullPath))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -128,7 +128,8 @@
|
||||
dense
|
||||
:rules="[
|
||||
(val) =>
|
||||
state.invalidCharsRegex.test(val) || t('admin.login.loginRedirectInvalidChars')
|
||||
state.invalidCharsRegex.test(val) || t('admin.login.loginRedirectInvalidChars'),
|
||||
(val) => !val || isRedirectTarget(val) || t('admin.login.redirectInvalid')
|
||||
]"
|
||||
hide-bottom-space
|
||||
:aria-label="t(`admin.login.loginRedirect`)" />
|
||||
@@ -149,7 +150,8 @@
|
||||
:rules="[
|
||||
(val) =>
|
||||
state.invalidCharsRegex.test(val) ||
|
||||
t('admin.login.welcomeRedirectInvalidChars')
|
||||
t('admin.login.welcomeRedirectInvalidChars'),
|
||||
(val) => !val || isRedirectTarget(val) || t('admin.login.redirectInvalid')
|
||||
]"
|
||||
hide-bottom-space
|
||||
:aria-label="t(`admin.login.welcomeRedirect`)" />
|
||||
@@ -169,7 +171,9 @@
|
||||
dense
|
||||
:rules="[
|
||||
(val) =>
|
||||
state.invalidCharsRegex.test(val) || t('admin.login.logoutRedirectInvalidChars')
|
||||
state.invalidCharsRegex.test(val) ||
|
||||
t('admin.login.logoutRedirectInvalidChars'),
|
||||
(val) => !val || isRedirectTarget(val) || t('admin.login.redirectInvalid')
|
||||
]"
|
||||
hide-bottom-space
|
||||
:aria-label="t(`admin.login.logoutRedirect`)" />
|
||||
@@ -238,6 +242,8 @@ import { onMounted, reactive, watch } from 'vue'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { loading } from '@/composables/loading'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { isRedirectTarget } from '@/helpers/loginRedirect'
|
||||
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
@@ -369,7 +375,8 @@ async function save() {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: 'Failed to save login configuration.',
|
||||
caption: err.message
|
||||
// -> ky throws above 400, with the server's reason in the body
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
|
||||
@@ -28,14 +28,11 @@
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const actions = {
|
||||
unauthorized: {
|
||||
code: 403,
|
||||
@@ -56,12 +53,6 @@ const actions = {
|
||||
// ROUTER
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// I18N
|
||||
|
||||
@@ -73,26 +64,6 @@ useMeta(() => ({
|
||||
title: t('common.error.title')
|
||||
}))
|
||||
|
||||
// MOUNTED
|
||||
|
||||
/*
|
||||
A site can choose to skip this screen entirely for a visitor who is not logged in: with
|
||||
`bypassUnauthorized` on, being refused a page sends them to sign in rather than to a page whose only
|
||||
purpose is to offer them a login button.
|
||||
|
||||
Only when nobody is logged in. Somebody who IS signed in and still refused has nothing to gain from
|
||||
the login screen, and sending them there would bounce them straight back.
|
||||
*/
|
||||
onMounted(() => {
|
||||
if (
|
||||
route.params.action === 'unauthorized' &&
|
||||
siteStore.auth.bypassUnauthorized &&
|
||||
!userStore.authenticated
|
||||
) {
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const error = computed(() => {
|
||||
|
||||
@@ -70,6 +70,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { loginLocation } from '@/helpers/loginRedirect'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -148,7 +149,9 @@ async function unsubscribe(scope) {
|
||||
|
||||
/** The full settings, which need a session — the login screen comes first for somebody without one. */
|
||||
function goToSettings() {
|
||||
router.push(userStore.authenticated ? '/_profile/notifications' : '/login')
|
||||
router.push(
|
||||
userStore.authenticated ? '/_profile/notifications' : loginLocation('/_profile/notifications')
|
||||
)
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
@@ -157,6 +157,11 @@ export const useSiteStore = defineStore('site', {
|
||||
},
|
||||
/** How this site handles signing in. Set in the admin area's Login section. */
|
||||
auth: {
|
||||
/**
|
||||
* Skip the login screen and go straight to the first strategy, where that one signs in at a
|
||||
* provider rather than with a form here.
|
||||
*/
|
||||
autoLogin: false,
|
||||
/**
|
||||
* Send a visitor who is not logged in straight to the login screen instead of showing them
|
||||
* the unauthorized page. For a wiki that is closed to the public, that screen is a dead end
|
||||
|
||||
Reference in new issue
Block a user