mirror of https://github.com/requarks/wiki
parent
b87c236d4a
commit
b7dc7b8357
@ -0,0 +1,94 @@
|
||||
import { SCIM_DELETE_ACTIONS, SCIM_EMAIL_SOURCES } from '../../models/scim.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* SCIM CONFIG - Used both ways: as the response, and as a partial update body
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'ScimConfig',
|
||||
type: 'object',
|
||||
properties: {
|
||||
isEnabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the SCIM 2.0 endpoint is served at `/_scim/v2`. Off, every path under it answers 404 whatever credential is presented.'
|
||||
},
|
||||
deleteAction: {
|
||||
type: 'string',
|
||||
enum: [...SCIM_DELETE_ACTIONS],
|
||||
description:
|
||||
"`deactivate` (the default) answers `DELETE /Users/:id` by clearing the account's sessions and group memberships while keeping the row, so authorship on pages and history survives. `delete` removes the row outright."
|
||||
},
|
||||
emailSource: {
|
||||
type: 'string',
|
||||
enum: [...SCIM_EMAIL_SOURCES],
|
||||
description:
|
||||
"Where a provisioned account's email address is read from. `userName` is what every connector sends and is right wherever the login name is the mailbox; `emails` reads the primary entry of `emails[]` instead."
|
||||
},
|
||||
allowGroupCreate: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether `POST /Groups` may create a wiki group. A group created this way holds the same starting permissions as one created in the admin area and grants nothing beyond them. Off, a directory may only manage the membership of groups that already exist here.'
|
||||
},
|
||||
rateLimitEnabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether requests to `/_scim/v2` are rate limited per client address. Counted against the same postgres-backed counter the login limit uses, so instances behind a load balancer share one budget.'
|
||||
},
|
||||
rateLimitMax: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
description:
|
||||
"Requests one address may make within the window. Set well above what a sync costs: a directory's first run is every user it has, back to back."
|
||||
},
|
||||
rateLimitWindow: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
description: 'Length of the window, as a number and a unit — `30s`, `1m`, `1h`.'
|
||||
},
|
||||
rateLimitBan: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
description:
|
||||
'How long an address is refused once it goes over, in the same notation. Short by default, so a connector that trips the limit recovers on its next cycle instead of leaving provisioning broken.'
|
||||
},
|
||||
ipAllowList: {
|
||||
type: 'array',
|
||||
items: { type: 'string', maxLength: 64 },
|
||||
description:
|
||||
'Addresses allowed to reach `/_scim/v2`, as single addresses or CIDR subnets (`203.0.113.4`, `203.0.113.0/24`, `2001:db8::/32`). EMPTY means no restriction, leaving the bearer token as the only thing in front of the endpoint. What an address means depends on `security.trustProxy`.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* SCIM STATUS - What the admin screen shows beside the settings
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'ScimStatus',
|
||||
type: 'object',
|
||||
properties: {
|
||||
users: {
|
||||
type: 'integer',
|
||||
description: 'How many user accounts a directory currently owns.'
|
||||
},
|
||||
groups: {
|
||||
type: 'integer',
|
||||
description: 'How many groups a directory currently owns.'
|
||||
},
|
||||
lastRequest: {
|
||||
type: ['object', 'null'],
|
||||
description:
|
||||
'The last SCIM request THIS instance answered. Held in memory, so it is empty after a restart and, in a high-availability set, says nothing about what the other instances have served.',
|
||||
properties: {
|
||||
at: { type: 'string' },
|
||||
method: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
status: { type: 'integer' },
|
||||
message: { type: ['string', 'null'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,808 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { CustomError, originOf } from '../helpers/common.ts'
|
||||
import { elevatedGroupGuard, systemUserGuard } from '../helpers/userGuards.ts'
|
||||
import {
|
||||
SCHEMA_ERROR,
|
||||
SCHEMA_GROUP,
|
||||
SCHEMA_USER,
|
||||
SCIM_CONTENT_TYPE,
|
||||
SCIM_MAX_RESULTS,
|
||||
SCIM_PERMISSION,
|
||||
ScimError
|
||||
} from '../models/scim.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
/**
|
||||
* SCIM 2.0, at `/_scim/v2`.
|
||||
*
|
||||
* A controller rather than a route plugin under `api/`, and every one of the four reasons is
|
||||
* load-bearing:
|
||||
*
|
||||
* - **The error body.** RFC 7644 §3.12 gives a refusal its own shape, with a `scimType` a
|
||||
* connector branches on. The `/_api/` error handler in `index.ts` produces a different one, so
|
||||
* this plugin sets its own inside its encapsulation context.
|
||||
* - **The content type** is `application/scim+json`. Parsed here and nowhere else, so every other
|
||||
* route in the wiki goes on refusing it.
|
||||
* - **The 404 body** has to be a SCIM error too, which is a not-found handler of its own.
|
||||
* - **OpenAPI.** `hideUntagged` is on and nothing here declares a tag, so SCIM stays out of the
|
||||
* API docs — it is described by its own RFC and by the discovery endpoints below.
|
||||
*
|
||||
* Authorization is `manage:scim`, held as a bearer API key (the usual case — a connector) or by a
|
||||
* signed-in session (which is what makes the endpoint drivable by hand while it is being set up).
|
||||
* It is checked in this plugin's own hook rather than through `config.permissions`, because the
|
||||
* enabled check has to come first — a wiki that has not turned provisioning on answers 404, not 401
|
||||
* — and because every refusal on this prefix has to leave as a SCIM error.
|
||||
*
|
||||
* What it may then DO is not decided here: `helpers/userGuards.ts` holds the same three guards the
|
||||
* admin API goes through, so a directory cannot reach through `/_scim` for something an
|
||||
* administrator could not do through `/_api`. In practice that means a SCIM client can never staff
|
||||
* the Administrators group, nor touch an account that belongs to it.
|
||||
*/
|
||||
|
||||
/** RFC 7644 §5 — what this service provider supports, which connectors read before they sync. */
|
||||
const SERVICE_PROVIDER_CONFIG = {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
|
||||
documentationUri: 'https://docs.js.wiki/admin/scim',
|
||||
patch: { supported: true },
|
||||
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
||||
filter: { supported: true, maxResults: SCIM_MAX_RESULTS },
|
||||
changePassword: { supported: false },
|
||||
sort: { supported: false },
|
||||
etag: { supported: false },
|
||||
authenticationSchemes: [
|
||||
{
|
||||
type: 'oauthbearertoken',
|
||||
name: 'OAuth Bearer Token',
|
||||
description:
|
||||
'An API key issued under Admin → API, belonging to a group that holds the manage:scim permission.',
|
||||
specUri: 'https://www.rfc-editor.org/rfc/rfc6750',
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const RESOURCE_TYPES = [
|
||||
{
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
|
||||
id: 'User',
|
||||
name: 'User',
|
||||
endpoint: '/Users',
|
||||
description: 'A wiki user account.',
|
||||
schema: SCHEMA_USER,
|
||||
schemaExtensions: []
|
||||
},
|
||||
{
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
|
||||
id: 'Group',
|
||||
name: 'Group',
|
||||
endpoint: '/Groups',
|
||||
description: 'A wiki group. Its permissions and page rules are set in the wiki, never here.',
|
||||
schema: SCHEMA_GROUP,
|
||||
schemaExtensions: []
|
||||
}
|
||||
]
|
||||
|
||||
/** A shorthand for the attribute declarations below, which are otherwise nine identical lines each. */
|
||||
function attr(name: string, overrides: Record<string, any> = {}): Record<string, any> {
|
||||
return {
|
||||
name,
|
||||
type: 'string',
|
||||
multiValued: false,
|
||||
required: false,
|
||||
caseExact: false,
|
||||
mutability: 'readWrite',
|
||||
returned: 'default',
|
||||
uniqueness: 'none',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two schemas, declaring only what this wiki actually stores.
|
||||
*
|
||||
* Deliberately short of RFC 7643's full User: a wiki account is a name, an address and whether it is
|
||||
* active. An attribute declared here that nothing could be written to would be a promise the mapping
|
||||
* does not keep.
|
||||
*/
|
||||
const SCHEMAS = [
|
||||
{
|
||||
id: SCHEMA_USER,
|
||||
name: 'User',
|
||||
description: 'A wiki user account.',
|
||||
attributes: [
|
||||
attr('userName', { required: true, uniqueness: 'server' }),
|
||||
{
|
||||
...attr('name'),
|
||||
type: 'complex',
|
||||
subAttributes: [attr('formatted'), attr('givenName'), attr('familyName')]
|
||||
},
|
||||
attr('displayName'),
|
||||
attr('title'),
|
||||
attr('timezone'),
|
||||
attr('active', { type: 'boolean' }),
|
||||
{
|
||||
...attr('emails'),
|
||||
type: 'complex',
|
||||
multiValued: true,
|
||||
subAttributes: [attr('value'), attr('type'), attr('primary', { type: 'boolean' })]
|
||||
},
|
||||
{
|
||||
...attr('groups', { mutability: 'readOnly' }),
|
||||
type: 'complex',
|
||||
multiValued: true,
|
||||
subAttributes: [
|
||||
attr('value', { mutability: 'readOnly' }),
|
||||
attr('display', { mutability: 'readOnly' }),
|
||||
attr('$ref', { type: 'reference', mutability: 'readOnly' })
|
||||
]
|
||||
}
|
||||
],
|
||||
meta: { resourceType: 'Schema', location: `/Schemas/${SCHEMA_USER}` }
|
||||
},
|
||||
{
|
||||
id: SCHEMA_GROUP,
|
||||
name: 'Group',
|
||||
description: 'A wiki group. Its permissions and page rules are set in the wiki, never here.',
|
||||
attributes: [
|
||||
attr('displayName', { required: true, uniqueness: 'server' }),
|
||||
{
|
||||
...attr('members'),
|
||||
type: 'complex',
|
||||
multiValued: true,
|
||||
subAttributes: [
|
||||
attr('value'),
|
||||
attr('display', { mutability: 'immutable' }),
|
||||
attr('$ref', { type: 'reference' })
|
||||
]
|
||||
}
|
||||
],
|
||||
meta: { resourceType: 'Schema', location: `/Schemas/${SCHEMA_GROUP}` }
|
||||
}
|
||||
]
|
||||
|
||||
/** Where `meta.location` and every `$ref` point, as this request reached the wiki. */
|
||||
function baseUrlFor(req: FastifyRequest): string {
|
||||
return `${originOf(req)}/_scim/v2`
|
||||
}
|
||||
|
||||
/** Send a resource, always under the SCIM media type. */
|
||||
function sendScim(reply: FastifyReply, status: number, body: unknown): FastifyReply {
|
||||
return reply.code(status).type(`${SCIM_CONTENT_TYPE}; charset=utf-8`).send(body)
|
||||
}
|
||||
|
||||
/** What a request holds, whether it arrived as a bearer key or as a browser session. */
|
||||
function permissionsOf(req: FastifyRequest): string[] | null {
|
||||
if (req.apiKey) {
|
||||
return req.apiKey.permissions
|
||||
}
|
||||
return req.session?.authenticated ? (req.session.permissions ?? []) : null
|
||||
}
|
||||
|
||||
/** A caller guard's refusal, as the SCIM error it has to leave as. */
|
||||
function asScimError(refusal: CustomError): ScimError {
|
||||
return new ScimError(refusal.statusCode, refusal.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a write, as an object.
|
||||
*
|
||||
* Both content types land here — `application/json` through Fastify's own parser and
|
||||
* `application/scim+json` through the one registered below — so this only has to catch the request
|
||||
* that carried nothing at all, which several connectors send while probing an endpoint.
|
||||
*/
|
||||
function resourceBody(req: FastifyRequest): Record<string, any> {
|
||||
const body = req.body
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new ScimError(400, 'A request body is required.', 'invalidSyntax')
|
||||
}
|
||||
return body as Record<string, any>
|
||||
}
|
||||
|
||||
async function routes(app: FastifyInstance) {
|
||||
/*
|
||||
RFC 7644 §3.1 gives SCIM its own media type. Registered inside this plugin, so that a body of
|
||||
`application/scim+json` posted anywhere else in the wiki goes on being refused.
|
||||
*/
|
||||
app.addContentTypeParser(
|
||||
[SCIM_CONTENT_TYPE],
|
||||
{ parseAs: 'string' },
|
||||
(_req, body: string | Buffer, done) => {
|
||||
const text = body.toString().trim()
|
||||
if (text.length < 1) {
|
||||
done(null, undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(text))
|
||||
} catch {
|
||||
done(new ScimError(400, 'The request body is not valid JSON.', 'invalidSyntax'), undefined)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ----------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------
|
||||
|
||||
app.setErrorHandler((error: any, req, reply) => {
|
||||
const statusCode: number =
|
||||
error instanceof ScimError ? error.statusCode : (error.statusCode ?? 500)
|
||||
const isFault = statusCode >= 500
|
||||
if (isFault) {
|
||||
WIKI.logger.warn(`SCIM ${req.method} ${req.url} failed: ${error.message}`)
|
||||
}
|
||||
const detail = isFault ? 'Internal server error.' : error.message
|
||||
WIKI.models.scim.recordRequest({
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
status: statusCode,
|
||||
message: detail
|
||||
})
|
||||
return sendScim(reply, statusCode, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: String(statusCode),
|
||||
...(error instanceof ScimError && error.scimType ? { scimType: error.scimType } : {}),
|
||||
detail
|
||||
})
|
||||
})
|
||||
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
WIKI.models.scim.recordRequest({
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
status: 404,
|
||||
message: 'No such SCIM endpoint.'
|
||||
})
|
||||
return sendScim(reply, 404, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: '404',
|
||||
detail: `No SCIM endpoint answers ${req.method} ${req.url}.`
|
||||
})
|
||||
})
|
||||
|
||||
// ----------------------------------------
|
||||
// Access
|
||||
// ----------------------------------------
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
if (!WIKI.models.scim.isEnabled()) {
|
||||
/*
|
||||
404 rather than 403: with provisioning off there is no endpoint here, and a connector pointed
|
||||
at a wiki that has not turned it on should be told the URL is wrong rather than that its
|
||||
token is. The message names the feature, which is in the manual anyway.
|
||||
*/
|
||||
return sendScim(reply, 404, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: '404',
|
||||
detail: 'SCIM provisioning is not enabled on this wiki.'
|
||||
})
|
||||
}
|
||||
/*
|
||||
The address check comes before everything else that costs anything: it is the only gate here
|
||||
that needs neither the database nor a signature, and an operator who has written a list has
|
||||
said requests from anywhere else are not to be entertained at all.
|
||||
|
||||
403 rather than 404. Hiding the endpoint from an address would be pointless — it is a fixed,
|
||||
documented path on a wiki that is answering on every other one — and a connector moved to a
|
||||
new egress range needs to be told which of the two things is wrong.
|
||||
*/
|
||||
if (!WIKI.models.scim.isAddressAllowed(req.ip)) {
|
||||
WIKI.logger.debug(`Refused a SCIM request from ${req.ip}: not in the allowed address list.`)
|
||||
return sendScim(reply, 403, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: '403',
|
||||
detail: 'This address is not allowed to reach the SCIM endpoint.'
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
Then the limit, and before the credential check rather than after it, so that an unauthorized
|
||||
flood is capped as well as an authorized one — the request being refused is exactly when the
|
||||
counter matters. Counted per address against the same postgres-backed counter the login limit
|
||||
uses, so two instances behind a load balancer share one budget.
|
||||
|
||||
Successes are counted too, as they are for auth. A sync is what this endpoint is FOR, so the
|
||||
ceiling is set high enough that an ordinary one never approaches it; see `base.yml`.
|
||||
*/
|
||||
const config = WIKI.models.scim.getConfig()
|
||||
if (config.rateLimitEnabled) {
|
||||
const verdict = await WIKI.models.rateLimits.consume(
|
||||
`scim:${req.ip}`,
|
||||
WIKI.models.scim.rateLimitPolicy()
|
||||
)
|
||||
if (!verdict.allowed) {
|
||||
WIKI.logger.debug(
|
||||
`Rate limit: refused a SCIM request from ${req.ip}, ${verdict.retryAfter}s left of its ban.`
|
||||
)
|
||||
// -> `Retry-After` because this is the same answer as before with a time on it, and a
|
||||
// connector that reads it will come back rather than give up on the sync
|
||||
return sendScim(reply.header('Retry-After', String(verdict.retryAfter)), 429, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: '429',
|
||||
detail: `Too many requests. Try again in ${verdict.retryAfter}s.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const permissions = permissionsOf(req)
|
||||
if (!permissions) {
|
||||
return sendScim(reply.header('WWW-Authenticate', 'Bearer realm="scim"'), 401, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: '401',
|
||||
detail: 'This endpoint requires a bearer API key.'
|
||||
})
|
||||
}
|
||||
if (!permissions.includes(SCIM_PERMISSION) && !permissions.includes('manage:system')) {
|
||||
return sendScim(reply, 403, {
|
||||
schemas: [SCHEMA_ERROR],
|
||||
status: '403',
|
||||
detail: `This endpoint requires the ${SCIM_PERMISSION} permission.`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// -> Failures are recorded by the error handler above, which has the reason; this is the other half
|
||||
app.addHook('onResponse', async (req, reply) => {
|
||||
if (reply.statusCode < 400) {
|
||||
WIKI.models.scim.recordRequest({
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
status: reply.statusCode,
|
||||
message: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ----------------------------------------
|
||||
// Discovery
|
||||
// ----------------------------------------
|
||||
|
||||
app.get('/v2/ServiceProviderConfig', async (req, reply) =>
|
||||
sendScim(reply, 200, {
|
||||
...SERVICE_PROVIDER_CONFIG,
|
||||
meta: {
|
||||
resourceType: 'ServiceProviderConfig',
|
||||
location: `${baseUrlFor(req)}/ServiceProviderConfig`
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/v2/ResourceTypes', async (req, reply) => {
|
||||
const baseUrl = baseUrlFor(req)
|
||||
const resources = RESOURCE_TYPES.map((type) => ({
|
||||
...type,
|
||||
meta: { resourceType: 'ResourceType', location: `${baseUrl}/ResourceTypes/${type.id}` }
|
||||
}))
|
||||
return sendScim(reply, 200, WIKI.models.scim.listResponse(resources, resources.length, 1))
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/v2/ResourceTypes/:id', async (req, reply) => {
|
||||
const type = RESOURCE_TYPES.find((entry) => entry.id === req.params.id)
|
||||
if (!type) {
|
||||
throw new ScimError(404, `No resource type named '${req.params.id}'.`)
|
||||
}
|
||||
return sendScim(reply, 200, {
|
||||
...type,
|
||||
meta: {
|
||||
resourceType: 'ResourceType',
|
||||
location: `${baseUrlFor(req)}/ResourceTypes/${type.id}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/v2/Schemas', async (_req, reply) =>
|
||||
sendScim(reply, 200, WIKI.models.scim.listResponse(SCHEMAS, SCHEMAS.length, 1))
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>('/v2/Schemas/:id', async (req, reply) => {
|
||||
const schema = SCHEMAS.find((entry) => entry.id === req.params.id)
|
||||
if (!schema) {
|
||||
throw new ScimError(404, `No schema named '${req.params.id}'.`)
|
||||
}
|
||||
return sendScim(reply, 200, schema)
|
||||
})
|
||||
|
||||
// ----------------------------------------
|
||||
// Users
|
||||
// ----------------------------------------
|
||||
|
||||
/** One user as SCIM describes them, memberships included. */
|
||||
async function userResource(req: FastifyRequest, user: Record<string, any>) {
|
||||
const memberships = await WIKI.models.scim.membershipsOf([user.id])
|
||||
return WIKI.models.scim.toScimUser(user, memberships.get(user.id) ?? [], baseUrlFor(req))
|
||||
}
|
||||
|
||||
/** The user this request names, or the 404 that says nothing about why. */
|
||||
async function requireUser(id: string): Promise<Record<string, any>> {
|
||||
const user = await WIKI.models.scim.getUser(id)
|
||||
if (!user) {
|
||||
throw new ScimError(404, `No user with id '${id}'.`)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
app.get<{ Querystring: Record<string, any> }>('/v2/Users', async (req, reply) => {
|
||||
const { startIndex, count } = WIKI.models.scim.parsePaging(req.query)
|
||||
return sendScim(
|
||||
reply,
|
||||
200,
|
||||
await WIKI.models.scim.listUsers({
|
||||
filter: req.query.filter,
|
||||
startIndex,
|
||||
count,
|
||||
baseUrl: baseUrlFor(req)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) =>
|
||||
sendScim(reply, 200, await userResource(req, await requireUser(req.params.id)))
|
||||
)
|
||||
|
||||
app.post('/v2/Users', async (req, reply) => {
|
||||
const id = await WIKI.models.scim.createUser(resourceBody(req))
|
||||
const user = await requireUser(id)
|
||||
|
||||
await audit(req, 'admin', 'createUser', {
|
||||
source: 'scim',
|
||||
targetUserId: id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
externalId: user.externalId
|
||||
})
|
||||
|
||||
const resource = await userResource(req, user)
|
||||
return sendScim(reply.header('Location', resource.meta.location), 201, resource)
|
||||
})
|
||||
|
||||
/**
|
||||
* Replace a user, and adopt it if it was not already provisioned.
|
||||
*
|
||||
* Only the attributes the resource carries are applied. A SCIM PUT is nominally a whole-resource
|
||||
* replace, but this wiki has fields SCIM does not describe and no notion of an unset name — so an
|
||||
* attribute a connector left out leaves the stored value alone rather than blanking it.
|
||||
*/
|
||||
app.put<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) => {
|
||||
const user = await requireUser(req.params.id)
|
||||
const refusal = await systemUserGuard(req, user.id)
|
||||
if (refusal) {
|
||||
throw asScimError(refusal)
|
||||
}
|
||||
|
||||
await WIKI.models.scim.applyUser(user, resourceBody(req))
|
||||
const updated = await requireUser(user.id)
|
||||
|
||||
await audit(req, 'admin', 'updateUser', {
|
||||
source: 'scim',
|
||||
targetUserId: user.id,
|
||||
targetName: updated.name,
|
||||
targetEmail: updated.email,
|
||||
isActive: updated.isActive
|
||||
})
|
||||
|
||||
return sendScim(reply, 200, await userResource(req, updated))
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) => {
|
||||
const user = await requireUser(req.params.id)
|
||||
const refusal = await systemUserGuard(req, user.id)
|
||||
if (refusal) {
|
||||
throw asScimError(refusal)
|
||||
}
|
||||
|
||||
const fragment = WIKI.models.scim.parseUserPatch(resourceBody(req))
|
||||
await WIKI.models.scim.applyUser(user, fragment)
|
||||
const updated = await requireUser(user.id)
|
||||
|
||||
await audit(req, 'admin', 'updateUser', {
|
||||
source: 'scim',
|
||||
targetUserId: user.id,
|
||||
targetName: updated.name,
|
||||
targetEmail: updated.email,
|
||||
isActive: updated.isActive,
|
||||
changedFields: Object.keys(fragment)
|
||||
})
|
||||
|
||||
return sendScim(reply, 200, await userResource(req, updated))
|
||||
})
|
||||
|
||||
/**
|
||||
* Deprovision a user.
|
||||
*
|
||||
* Only for an account the directory owns: one created here and never written by a connector
|
||||
* answers 404, which is SCIM's way of saying "not a resource of mine". That is what keeps a token
|
||||
* sitting in somebody else's console from emptying the wiki's user list, and it costs nothing —
|
||||
* a connector adopts an account the first time it writes to one.
|
||||
*
|
||||
* What deprovisioning MEANS is the site's `deleteAction` setting. See `models/scim.ts`.
|
||||
*/
|
||||
app.delete<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) => {
|
||||
const user = await requireUser(req.params.id)
|
||||
if (!user.isProvisioned) {
|
||||
throw new ScimError(
|
||||
404,
|
||||
`The user '${user.email}' was not created by provisioning, so it cannot be removed by it.`
|
||||
)
|
||||
}
|
||||
const refusal = await systemUserGuard(req, user.id)
|
||||
if (refusal) {
|
||||
throw asScimError(refusal)
|
||||
}
|
||||
|
||||
const action = await WIKI.models.scim.deprovisionUser(user.id)
|
||||
|
||||
await audit(req, 'admin', action === 'delete' ? 'deleteUser' : 'updateUser', {
|
||||
source: 'scim',
|
||||
deprovisioned: action,
|
||||
targetUserId: user.id,
|
||||
targetName: user.name,
|
||||
targetEmail: user.email
|
||||
})
|
||||
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
// ----------------------------------------
|
||||
// Groups
|
||||
// ----------------------------------------
|
||||
|
||||
async function groupResource(req: FastifyRequest, group: Record<string, any>) {
|
||||
const members = await WIKI.models.scim.membersOf([group.id])
|
||||
return WIKI.models.scim.toScimGroup(group, members.get(group.id) ?? [], baseUrlFor(req))
|
||||
}
|
||||
|
||||
async function requireGroup(id: string): Promise<Record<string, any>> {
|
||||
const group = await WIKI.models.scim.getGroup(id)
|
||||
if (!group) {
|
||||
throw new ScimError(404, `No group with id '${id}'.`)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a membership change the caller may not make.
|
||||
*
|
||||
* Two separate questions, and both have to be asked. `elevatedGroupGuard` is about the GROUP: a
|
||||
* SCIM client holds `manage:scim` and not `manage:groups`, so every group carrying an elevated
|
||||
* permission is closed to it — which is precisely what stops a directory group called
|
||||
* "Administrators" from syncing its membership into the wiki's. `systemUserGuard` is about each
|
||||
* PERSON being moved: an account protected by `manage:system` is not re-grouped by anything short
|
||||
* of `manage:system`.
|
||||
*/
|
||||
async function guardMembership(
|
||||
req: FastifyRequest,
|
||||
groupId: string,
|
||||
touched: string[]
|
||||
): Promise<void> {
|
||||
const full = await WIKI.models.groups.getGroupById(groupId)
|
||||
if (!full) {
|
||||
throw new ScimError(404, `No group with id '${groupId}'.`)
|
||||
}
|
||||
const groupRefusal = elevatedGroupGuard(req, full, 'change who belongs to the group')
|
||||
if (groupRefusal) {
|
||||
throw asScimError(groupRefusal)
|
||||
}
|
||||
for (const userId of touched) {
|
||||
const userRefusal = await systemUserGuard(req, userId)
|
||||
if (userRefusal) {
|
||||
throw asScimError(userRefusal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a group's membership to exactly `target`, one assignment at a time.
|
||||
*
|
||||
* Not `users.setUserGroups`, which replaces one user's whole membership and would take them out of
|
||||
* every other group in the wiki. `assignUserToGroup` and its opposite are per membership, and are
|
||||
* also where the guest account's fixed membership is enforced.
|
||||
*/
|
||||
async function applyMembership(
|
||||
req: FastifyRequest,
|
||||
groupId: string,
|
||||
target: string[]
|
||||
): Promise<{ added: string[]; removed: string[] }> {
|
||||
const current = await WIKI.models.scim.memberIdsOf(groupId)
|
||||
const wanted = [...new Set(target)]
|
||||
|
||||
const unknown = await WIKI.models.scim.firstUnknownUser(wanted)
|
||||
if (unknown) {
|
||||
throw new ScimError(400, `No user with id '${unknown}'.`, 'invalidValue')
|
||||
}
|
||||
|
||||
const added = wanted.filter((id) => !current.includes(id))
|
||||
const removed = current.filter((id) => !wanted.includes(id))
|
||||
if (added.length < 1 && removed.length < 1) {
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
await guardMembership(req, groupId, [...added, ...removed])
|
||||
for (const userId of added) {
|
||||
await WIKI.models.groups.assignUserToGroup(groupId, userId)
|
||||
}
|
||||
for (const userId of removed) {
|
||||
await WIKI.models.groups.unassignUserFromGroup(groupId, userId)
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/** The ids a `members` array names, for a PUT or a create. */
|
||||
function memberIdsFrom(resource: Record<string, any>): string[] {
|
||||
if (!Array.isArray(resource.members)) {
|
||||
return []
|
||||
}
|
||||
return resource.members.map((entry: any) => {
|
||||
const id = typeof entry === 'string' ? entry : entry?.value
|
||||
if (typeof id !== 'string' || id.length < 1) {
|
||||
throw new ScimError(
|
||||
400,
|
||||
'Each member must carry a `value` naming a user id.',
|
||||
'invalidValue'
|
||||
)
|
||||
}
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
app.get<{ Querystring: Record<string, any> }>('/v2/Groups', async (req, reply) => {
|
||||
const { startIndex, count } = WIKI.models.scim.parsePaging(req.query)
|
||||
return sendScim(
|
||||
reply,
|
||||
200,
|
||||
await WIKI.models.scim.listGroups({
|
||||
filter: req.query.filter,
|
||||
startIndex,
|
||||
count,
|
||||
baseUrl: baseUrlFor(req)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) =>
|
||||
sendScim(reply, 200, await groupResource(req, await requireGroup(req.params.id)))
|
||||
)
|
||||
|
||||
app.post('/v2/Groups', async (req, reply) => {
|
||||
const body = resourceBody(req)
|
||||
const members = memberIdsFrom(body)
|
||||
const id = await WIKI.models.scim.createGroup(body)
|
||||
const group = await requireGroup(id)
|
||||
|
||||
await audit(req, 'admin', 'createGroup', {
|
||||
source: 'scim',
|
||||
groupId: id,
|
||||
name: group.name,
|
||||
externalId: group.externalId
|
||||
})
|
||||
|
||||
if (members.length > 0) {
|
||||
const { added } = await applyMembership(req, id, members)
|
||||
if (added.length > 0) {
|
||||
await audit(req, 'admin', 'assignUserToGroup', {
|
||||
source: 'scim',
|
||||
groupId: id,
|
||||
name: group.name,
|
||||
userIds: added
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resource = await groupResource(req, await requireGroup(id))
|
||||
return sendScim(reply.header('Location', resource.meta.location), 201, resource)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) => {
|
||||
const group = await requireGroup(req.params.id)
|
||||
const body = resourceBody(req)
|
||||
|
||||
const refusal = elevatedGroupGuard(
|
||||
req,
|
||||
(await WIKI.models.groups.getGroupById(group.id))!,
|
||||
'modify the group'
|
||||
)
|
||||
if (refusal) {
|
||||
throw asScimError(refusal)
|
||||
}
|
||||
|
||||
await WIKI.models.scim.applyGroup(group, {
|
||||
displayName: body.displayName,
|
||||
externalId: body.externalId === undefined ? undefined : body.externalId
|
||||
})
|
||||
// -> A PUT states the membership in full, so anybody it does not name is out of the group
|
||||
const { added, removed } = await applyMembership(req, group.id, memberIdsFrom(body))
|
||||
|
||||
await audit(req, 'admin', 'updateGroup', {
|
||||
source: 'scim',
|
||||
groupId: group.id,
|
||||
name: body.displayName ?? group.name,
|
||||
added,
|
||||
removed
|
||||
})
|
||||
|
||||
return sendScim(reply, 200, await groupResource(req, await requireGroup(group.id)))
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) => {
|
||||
const group = await requireGroup(req.params.id)
|
||||
const ops = WIKI.models.scim.parseGroupPatch(resourceBody(req))
|
||||
|
||||
if (ops.displayName !== undefined || ops.externalId !== undefined) {
|
||||
const refusal = elevatedGroupGuard(
|
||||
req,
|
||||
(await WIKI.models.groups.getGroupById(group.id))!,
|
||||
'modify the group'
|
||||
)
|
||||
if (refusal) {
|
||||
throw asScimError(refusal)
|
||||
}
|
||||
await WIKI.models.scim.applyGroup(group, {
|
||||
displayName: ops.displayName,
|
||||
externalId: ops.externalId
|
||||
})
|
||||
}
|
||||
|
||||
let changed: { added: string[]; removed: string[] } = { added: [], removed: [] }
|
||||
const touchesMembers =
|
||||
ops.removeAllMembers ||
|
||||
ops.replaceMembers !== undefined ||
|
||||
ops.addMembers.length > 0 ||
|
||||
ops.removeMembers.length > 0
|
||||
if (touchesMembers) {
|
||||
const current = await WIKI.models.scim.memberIdsOf(group.id)
|
||||
const base = ops.removeAllMembers ? [] : (ops.replaceMembers ?? current)
|
||||
const target = [...base, ...ops.addMembers].filter((id) => !ops.removeMembers.includes(id))
|
||||
changed = await applyMembership(req, group.id, target)
|
||||
}
|
||||
|
||||
await audit(req, 'admin', 'updateGroup', {
|
||||
source: 'scim',
|
||||
groupId: group.id,
|
||||
name: ops.displayName ?? group.name,
|
||||
added: changed.added,
|
||||
removed: changed.removed
|
||||
})
|
||||
|
||||
return sendScim(reply, 200, await groupResource(req, await requireGroup(group.id)))
|
||||
})
|
||||
|
||||
/**
|
||||
* Delete a group the directory owns.
|
||||
*
|
||||
* Gated on `isProvisioned` for the same reason a user is, and additionally closed for a built-in
|
||||
* group: the guests, users and administrators groups are what anonymous access, the default
|
||||
* membership and the root administrator resolve against, and nothing outside the wiki gets to
|
||||
* take one away.
|
||||
*/
|
||||
app.delete<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) => {
|
||||
const group = await requireGroup(req.params.id)
|
||||
if (group.isSystem) {
|
||||
throw new ScimError(403, `The '${group.name}' group is built in and cannot be deleted.`)
|
||||
}
|
||||
if (!group.isProvisioned) {
|
||||
throw new ScimError(
|
||||
404,
|
||||
`The group '${group.name}' was not created by provisioning, so it cannot be removed by it.`
|
||||
)
|
||||
}
|
||||
const refusal = elevatedGroupGuard(
|
||||
req,
|
||||
(await WIKI.models.groups.getGroupById(group.id))!,
|
||||
'delete the group'
|
||||
)
|
||||
if (refusal) {
|
||||
throw asScimError(refusal)
|
||||
}
|
||||
|
||||
await WIKI.models.scim.deleteGroup(group.id)
|
||||
|
||||
await audit(req, 'admin', 'deleteGroup', {
|
||||
source: 'scim',
|
||||
groupId: group.id,
|
||||
name: group.name
|
||||
})
|
||||
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,6 @@
|
||||
ALTER TABLE "groups" ADD COLUMN "externalId" varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "groups" ADD COLUMN "isProvisioned" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "externalId" varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "isProvisioned" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_externalId_idx" ON "groups" ("externalId");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_externalId_idx" ON "users" ("externalId");
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,142 @@
|
||||
import { CustomError } from './common.ts'
|
||||
import { ELEVATED_PERMISSIONS, SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
|
||||
import type { FastifyRequest } from 'fastify'
|
||||
import type { GroupWithUserCount } from '../models/groups.ts'
|
||||
|
||||
/**
|
||||
* The three guards that stand between an administrator and the accounts that administer the wiki.
|
||||
*
|
||||
* They live here rather than in the models because every one of them is a question about the
|
||||
* CALLER — what the session or the API key making this request holds — and a model is reachable
|
||||
* from the scheduler, where there is no caller to ask about. They live here rather than in
|
||||
* `api/users.ts` because there is now more than one surface that writes users and groups: the admin
|
||||
* API, and the SCIM endpoint under `/_scim`, which a directory drives with a bearer token. A guard
|
||||
* that only one of the two went through would be a guard with a way around it.
|
||||
*
|
||||
* All three answer with the refusal to throw rather than throwing it themselves, so that a caller
|
||||
* can decide whether a refusal is an error or, as SCIM needs, a 404 that discloses nothing.
|
||||
*/
|
||||
|
||||
/** What a request holds, whether it arrived as a session or as an API key. */
|
||||
function permissionsOf(req: FastifyRequest): string[] {
|
||||
return req.apiKey?.permissions ?? req.session?.permissions ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse any change to a user who is protected by `manage:system`.
|
||||
*
|
||||
* `manage:users` is deliberately short of the root: an administrator who can rename, re-group, reset
|
||||
* the password of, or delete a `manage:system` account can take the instance over through it. Only
|
||||
* somebody who already holds `manage:system` may touch one.
|
||||
*
|
||||
* @returns The refusal to throw, or null when the caller may proceed
|
||||
*/
|
||||
export async function systemUserGuard(
|
||||
req: FastifyRequest,
|
||||
userId: string
|
||||
): Promise<CustomError | null> {
|
||||
if (WIKI.models.groups.holdsSystemPermission(req)) {
|
||||
return null
|
||||
}
|
||||
if (!(await WIKI.models.groups.userHoldsSystemPermission(userId))) {
|
||||
return null
|
||||
}
|
||||
return new CustomError(
|
||||
'userSystemProtected',
|
||||
'This user belongs to a group with the manage:system permission. Only a user who holds manage:system can modify them.',
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a change to who is in a group that administers the instance.
|
||||
*
|
||||
* Membership of such a group IS the permission: adding somebody hands them what the group can reach,
|
||||
* and removing somebody takes it away from a real administrator. Deleting the group does both at
|
||||
* once, so it asks the same question.
|
||||
*
|
||||
* Where the line falls depends on what the caller holds, and the rungs are deliberately different:
|
||||
*
|
||||
* - **`manage:groups`** is stopped only by `manage:system`, the permission that bypasses every check
|
||||
* on the server. Everything below that is theirs to arrange; managing groups is the job.
|
||||
* - **Everything else** — `write:groups`, and `manage:scim` on a SCIM request — is stopped by every
|
||||
* one of `ELEVATED_PERMISSIONS`. Those are the rungs that may build and populate ordinary groups
|
||||
* without being trusted to decide who administers the wiki, and since neither can edit a group's
|
||||
* permissions at all, their only route to an elevated group would be through the membership of one
|
||||
* that already exists.
|
||||
*
|
||||
* @param action What the caller was trying to do, as the message reads it back to them
|
||||
* @returns The refusal to throw, or null when the caller may proceed
|
||||
*/
|
||||
export function elevatedGroupGuard(
|
||||
req: FastifyRequest,
|
||||
group: GroupWithUserCount,
|
||||
action = 'change who belongs to the group'
|
||||
): CustomError | null {
|
||||
if (WIKI.models.groups.holdsSystemPermission(req)) {
|
||||
return null
|
||||
}
|
||||
const permissions = permissionsOf(req)
|
||||
if (permissions.includes('manage:groups')) {
|
||||
if (!group.permissions.includes(SYSTEM_PERMISSION)) {
|
||||
return null
|
||||
}
|
||||
return new CustomError(
|
||||
'groupMembershipSystemProtected',
|
||||
`This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`,
|
||||
403
|
||||
)
|
||||
}
|
||||
if (!isElevated(group.permissions)) {
|
||||
return null
|
||||
}
|
||||
const held = group.permissions.filter((p) =>
|
||||
(ELEVATED_PERMISSIONS as readonly string[]).includes(p)
|
||||
)
|
||||
return new CustomError(
|
||||
'groupMembershipElevatedProtected',
|
||||
`This group administers the wiki (${held.join(', ')}). Only a user who holds manage:groups or manage:system can ${action}.`,
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse moving a user into or out of a group that administers the wiki.
|
||||
*
|
||||
* Both directions, because adding hands them whatever that group can reach and removing takes it
|
||||
* from a real administrator. Creating an account already inside one is the same act as promoting an
|
||||
* existing one, so a create asks this with an empty `current` rather than skipping it — otherwise
|
||||
* the way around every other guard would be to make a second account instead of editing the first.
|
||||
*
|
||||
* Groups the request leaves alone are never consulted, so a save that only renames a user still goes
|
||||
* through whatever they already belong to.
|
||||
*
|
||||
* @param current The groups the user is in now — empty when the user is being created
|
||||
* @param requested The membership being asked for, in full
|
||||
* @returns The refusal to throw, or null when the caller may proceed
|
||||
*/
|
||||
export async function elevatedMembershipGuard(
|
||||
req: FastifyRequest,
|
||||
current: readonly string[],
|
||||
requested: readonly string[]
|
||||
): Promise<CustomError | null> {
|
||||
if (WIKI.models.groups.holdsSystemPermission(req)) {
|
||||
return null
|
||||
}
|
||||
const moved = [
|
||||
...requested.filter((id) => !current.includes(id)),
|
||||
...current.filter((id) => !requested.includes(id))
|
||||
]
|
||||
if (moved.length < 1) {
|
||||
return null
|
||||
}
|
||||
const elevated = await WIKI.models.groups.elevatedGroupIds()
|
||||
if (!moved.some((id) => elevated.includes(id))) {
|
||||
return null
|
||||
}
|
||||
return new CustomError(
|
||||
'groupMembershipElevatedProtected',
|
||||
'Only a user who holds manage:system can add a user to, or remove one from, a group that administers the wiki.',
|
||||
403
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<w-page class="admin-scim">
|
||||
<div class="flex flex-wrap p-4 items-center">
|
||||
<div class="flex-none">
|
||||
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-scim.svg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pl-4">
|
||||
<div class="text-h5 admin-page-title animated fadeInLeft">
|
||||
{{ t('admin.scim.title') }}
|
||||
</div>
|
||||
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
|
||||
{{ t('admin.scim.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center">
|
||||
<template v-if="state.enabled">
|
||||
<w-signal class="mr-2" color="green" size="md" />
|
||||
<div class="text-caption text-green">{{ t('admin.scim.enabled') }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<w-signal class="mr-2" color="red" size="md" />
|
||||
<div class="text-caption text-red">{{ t('admin.scim.disabled') }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<w-btn
|
||||
class="mr-2 ml-4 acrylic-btn"
|
||||
icon="la:question-circle"
|
||||
flat
|
||||
color="grey"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin/scim`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="acrylic-btn mr-2"
|
||||
icon="la:redo-alt"
|
||||
flat
|
||||
color="secondary"
|
||||
:loading="state.loading > 0"
|
||||
:aria-label="t(`common.actions.refresh`)"
|
||||
@click="refresh">
|
||||
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="mr-2"
|
||||
unelevated
|
||||
icon="la:power-off"
|
||||
:label="!state.enabled ? t(`common.actions.activate`) : t(`common.actions.deactivate`)"
|
||||
:color="!state.enabled ? `positive` : `negative`"
|
||||
@click="globalSwitch"
|
||||
:loading="state.isToggleLoading"
|
||||
:disabled="state.loading > 0" />
|
||||
<w-btn
|
||||
unelevated
|
||||
icon="mdi:check"
|
||||
:label="t(`common.actions.apply`)"
|
||||
color="secondary"
|
||||
@click="save"
|
||||
:loading="state.loading > 0" />
|
||||
</div>
|
||||
</div>
|
||||
<w-separator inset />
|
||||
<div class="grid grid-cols-12 p-4 gap-4">
|
||||
<div class="col-span-12 lg:col-span-6">
|
||||
<!-- ----------------------- -->
|
||||
<!-- Configuration -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2">
|
||||
<w-card-header>{{ t('admin.scim.configuration') }}</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon icon="trash" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.deleteAction`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.deleteActionHint`) }}</w-item-label>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<div>
|
||||
<w-radio
|
||||
v-model="state.config.deleteAction"
|
||||
val="deactivate"
|
||||
:label="t(`admin.scim.deleteActionDeactivate`)" />
|
||||
<div class="pl-7 text-caption text-grey">
|
||||
{{ t('admin.scim.deleteActionDeactivateHint') }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<w-radio
|
||||
v-model="state.config.deleteAction"
|
||||
val="delete"
|
||||
color="negative"
|
||||
:label="t(`admin.scim.deleteActionDelete`)" />
|
||||
<!-- -> Under the option rather than in the hint above: losing every page's
|
||||
authorship is the cost of this choice specifically -->
|
||||
<div class="pl-7 text-caption text-negative flex items-start">
|
||||
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
|
||||
<span>{{ t('admin.scim.deleteActionDeleteWarning') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="email" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.emailSource`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.emailSourceHint`) }}</w-item-label>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<w-radio
|
||||
v-model="state.config.emailSource"
|
||||
val="userName"
|
||||
:label="t(`admin.scim.emailSourceUserName`)" />
|
||||
<w-radio
|
||||
v-model="state.config.emailSource"
|
||||
val="emails"
|
||||
:label="t(`admin.scim.emailSourceEmails`)" />
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<!-- -> `tag="label"` makes the whole row operate the toggle, the way the editor config
|
||||
rows do: the browser forwards the click to the labelable control inside, so the label
|
||||
and its hint are part of the target rather than text beside one. -->
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="user-groups" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.allowGroupCreate`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.allowGroupCreateHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
v-model="state.config.allowGroupCreate"
|
||||
color="primary"
|
||||
:aria-label="t(`admin.scim.allowGroupCreate`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
|
||||
<!-- ----------------------- -->
|
||||
<!-- Access -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2 mt-4">
|
||||
<w-card-header>
|
||||
{{ t('admin.scim.access') }}
|
||||
<template #hint>{{ t('admin.scim.accessHint') }}</template>
|
||||
</w-card-header>
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="filtration" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.rateLimitEnabled`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.rateLimitEnabledHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
v-model="state.config.rateLimitEnabled"
|
||||
color="primary"
|
||||
:aria-label="t(`admin.scim.rateLimitEnabled`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<!-- -> The three numbers only mean anything while the limit is on, so they are not shown
|
||||
greyed out beside it; the Security screen's own limit does the same. -->
|
||||
<template v-if="state.config.rateLimitEnabled">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="pin-pad" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.rateLimitMax`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.rateLimitMaxHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section style="flex: 0 0 200px">
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
v-model.number="state.config.rateLimitMax"
|
||||
:suffix="t(`admin.scim.rateLimitMaxSuffix`)"
|
||||
:aria-label="t(`admin.scim.rateLimitMax`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="timer" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.rateLimitWindow`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.rateLimitWindowHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section style="flex: 0 0 200px">
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
v-model="state.config.rateLimitWindow"
|
||||
:placeholder="t(`admin.scim.durationPlaceholder`)"
|
||||
:aria-label="t(`admin.scim.rateLimitWindow`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="denied" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.rateLimitBan`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.rateLimitBanHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section style="flex: 0 0 200px">
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
v-model="state.config.rateLimitBan"
|
||||
:placeholder="t(`admin.scim.durationPlaceholder`)"
|
||||
:aria-label="t(`admin.scim.rateLimitBan`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="firewall" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.ipAllowList`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.ipAllowListHint`) }}</w-item-label>
|
||||
<!-- -> An address only means what it looks like when the proxy headers are trusted,
|
||||
so a list written against the directory's published egress ranges would refuse
|
||||
everybody. Same warning the Metrics screen carries over its address classes. -->
|
||||
<w-item-label
|
||||
v-if="!state.trustProxy"
|
||||
class="text-caption text-deep-orange mt-2 flex items-start">
|
||||
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
|
||||
<span>{{ t('admin.scim.proxyWarning') }}</span>
|
||||
</w-item-label>
|
||||
<w-input
|
||||
class="mt-2"
|
||||
outlined
|
||||
dense
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
v-model="state.ipAllowListText"
|
||||
:placeholder="t(`admin.scim.ipAllowListPlaceholder`)"
|
||||
:aria-label="t(`admin.scim.ipAllowList`)" />
|
||||
<!-- -> Says which of the two states the box is currently in, because an empty box
|
||||
meaning "everybody" is the opposite of what an empty allow list usually means -->
|
||||
<div class="text-caption text-grey mt-1">
|
||||
{{
|
||||
ipAllowListEntries.length > 0
|
||||
? t('admin.scim.ipAllowListRestricted', { count: ipAllowListEntries.length })
|
||||
: t('admin.scim.ipAllowListOpen')
|
||||
}}
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-6">
|
||||
<!-- ----------------------- -->
|
||||
<!-- Reference -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2">
|
||||
<w-card-header>{{ t('admin.scim.reference') }}</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon icon="link" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.tenantUrl`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.scim.tenantUrlHint`) }}</w-item-label>
|
||||
<!--
|
||||
-> Drawn as a read-only field rather than as a line of text: this is a value to be
|
||||
taken away and pasted somewhere else, and a box says "select me" where a paragraph
|
||||
does not. The fill is the one `WInput` gives its filled variant and the border the
|
||||
pair the rest of the library draws its edges with, so it sits in the card as an
|
||||
input would without being one.
|
||||
|
||||
-> The copy button belongs to the BOX, so it lives in this row rather than in a
|
||||
`side` section of the item: a side section is centred against the whole row --
|
||||
label, hint and box together -- which left the button floating opposite the hint
|
||||
instead of opposite the value it copies. `min-w-0` is what lets the box wrap
|
||||
inside the flex row rather than push the button off the end.
|
||||
-->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<div
|
||||
class="text-caption font-robotomono min-w-0 flex-1 break-all rounded border border-black/12 bg-black/4 px-3 py-2 dark:border-white/15 dark:bg-white/6">
|
||||
{{ tenantUrl }}
|
||||
</div>
|
||||
<w-btn
|
||||
class="acrylic-btn shrink-0"
|
||||
icon="la:copy"
|
||||
flat
|
||||
dense
|
||||
color="secondary"
|
||||
:aria-label="t(`common.actions.copy`)"
|
||||
@click="copyTenantUrl">
|
||||
<w-tooltip>{{ t(`common.actions.copy`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="key" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.scim.auth`) }}</w-item-label>
|
||||
<w-item-label caption>
|
||||
<i18n-t keypath="admin.scim.authHint" scope="global">
|
||||
<template #permission>
|
||||
<strong class="font-robotomono">manage:scim</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</w-item-label>
|
||||
<div class="text-caption mt-2">
|
||||
<i18n-t keypath="admin.scim.authApiKey" scope="global">
|
||||
<template #headerName>
|
||||
<strong class="font-robotomono">Authorization</strong>
|
||||
</template>
|
||||
<template #tokenType><strong class="font-robotomono">Bearer</strong></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<!-- -> Boxed like the tenant URL above, and for the same reason: it is a literal to
|
||||
be copied into a connector's configuration rather than prose to be read. -->
|
||||
<div
|
||||
class="text-caption font-robotomono mt-2 break-all rounded border border-black/12 bg-black/4 px-3 py-2 dark:border-white/15 dark:bg-white/6">
|
||||
Authorization: Bearer API-KEY-VALUE
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<!--
|
||||
-> Solid: minting the key is the one thing this card asks somebody to go and do, so
|
||||
it is an action rather than a link out. `WBtn` gives a solid button white text on
|
||||
its own, so no `text-color` is needed.
|
||||
|
||||
-> `dense` sets one padding for both axes (Quasar's 0.285em), which leaves the icon
|
||||
and the label flush against the ends. The override keeps the dense height and
|
||||
puts the standard 16px back on the sides, as the dialog action buttons do.
|
||||
-->
|
||||
<w-btn
|
||||
unelevated
|
||||
dense
|
||||
padding="xs md"
|
||||
color="primary"
|
||||
icon="la:external-link-alt"
|
||||
:label="t(`admin.scim.authGoToKeys`)"
|
||||
to="/_admin/api" />
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
|
||||
<!-- ----------------------- -->
|
||||
<!-- Status -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="mt-4">
|
||||
<w-card-header>
|
||||
{{ t('admin.scim.status') }}
|
||||
<template #hint>{{ t('admin.scim.statusHint') }}</template>
|
||||
</w-card-header>
|
||||
<w-card-section class="pt-0">
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<div class="text-h5">{{ state.status.users }}</div>
|
||||
<div class="text-caption text-grey">{{ t('admin.scim.provisionedUsers') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-h5">{{ state.status.groups }}</div>
|
||||
<div class="text-caption text-grey">{{ t('admin.scim.provisionedGroups') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<w-separator class="my-3" />
|
||||
<div class="text-caption text-grey">{{ t('admin.scim.lastRequest') }}</div>
|
||||
<div v-if="!state.status.lastRequest" class="text-caption mt-1">
|
||||
{{ t('admin.scim.lastRequestNone') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="text-caption font-robotomono mt-1 break-all">
|
||||
{{ state.status.lastRequest.method }} {{ state.status.lastRequest.path }}
|
||||
</div>
|
||||
<div class="text-caption mt-1 flex items-center">
|
||||
<w-icon
|
||||
class="mr-1"
|
||||
size="xs"
|
||||
:name="lastRequestOk ? 'la:check-circle' : 'la:exclamation-circle'"
|
||||
:class="lastRequestOk ? 'text-positive' : 'text-negative'" />
|
||||
<span :class="lastRequestOk ? 'text-positive' : 'text-negative'">
|
||||
{{ state.status.lastRequest.status }}
|
||||
</span>
|
||||
<span class="text-grey ml-2">{{ humanizeDate(state.status.lastRequest.at) }}</span>
|
||||
</div>
|
||||
<div v-if="state.status.lastRequest.message" class="text-caption text-grey mt-1">
|
||||
{{ state.status.lastRequest.message }}
|
||||
</div>
|
||||
</template>
|
||||
<!--
|
||||
-> Every API key is refused outright while the REST API is switched off, SCIM's
|
||||
included, and a connector reports that only as a 401 it cannot explain. It belongs
|
||||
in Status rather than beside the settings: nothing above is wrong, the endpoint
|
||||
simply cannot answer anybody until that switch is on.
|
||||
|
||||
-> The same solid red the Security screen uses for the two things that decide whether
|
||||
the settings around them do what they look like they do. This is one of those.
|
||||
-->
|
||||
<w-card v-if="!state.apiEnabled" class="bg-negative text-white mt-3 rounded" flat>
|
||||
<w-card-section class="items-center" horizontal>
|
||||
<w-card-section class="flex-none pr-0">
|
||||
<w-icon name="la:exclamation-triangle" size="lg" />
|
||||
</w-card-section>
|
||||
<w-card-section class="text-caption">
|
||||
<div>{{ t('admin.scim.authApiDisabled') }}</div>
|
||||
</w-card-section>
|
||||
</w-card-section>
|
||||
</w-card>
|
||||
</w-card-section>
|
||||
</w-card>
|
||||
</div>
|
||||
</div>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { loading } from '@/composables/loading'
|
||||
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { copyToClipboard } from '@/helpers/clipboard'
|
||||
|
||||
// STORES
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta(() => ({
|
||||
title: t('admin.scim.title')
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
enabled: false,
|
||||
loading: 0,
|
||||
isToggleLoading: false,
|
||||
// -> Read only, and only to warn: an API key authenticates nothing while the REST API is off
|
||||
apiEnabled: true,
|
||||
// -> Read only, and only to warn: with the proxy headers untrusted every request carries the
|
||||
// proxy's address, so an allow list would be matched against the wrong thing
|
||||
trustProxy: true,
|
||||
config: {
|
||||
deleteAction: 'deactivate',
|
||||
emailSource: 'userName',
|
||||
allowGroupCreate: true,
|
||||
rateLimitEnabled: true,
|
||||
rateLimitMax: 600,
|
||||
rateLimitWindow: '1m',
|
||||
rateLimitBan: '1m'
|
||||
},
|
||||
/*
|
||||
The allow list is edited as text, one entry per line, and stored as an array. Kept outside
|
||||
`config` for that reason: what the box holds is not what is saved, and a half-typed line must
|
||||
not disappear the moment it is not yet a valid address.
|
||||
*/
|
||||
ipAllowListText: '',
|
||||
status: {
|
||||
users: 0,
|
||||
groups: 0,
|
||||
lastRequest: null
|
||||
}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/*
|
||||
Built from the browser's own origin rather than from anything stored: the wiki has no single
|
||||
canonical hostname — a site may be bound to the catch-all — and the URL an administrator needs is
|
||||
the one their identity provider can actually reach, which is the one they are looking at.
|
||||
*/
|
||||
const tenantUrl = computed(() => `${window.location.origin}/_scim/v2`)
|
||||
|
||||
const lastRequestOk = computed(() => (state.status.lastRequest?.status ?? 500) < 400)
|
||||
|
||||
/** The textarea as the API wants it: trimmed, blanks dropped, one entry per line or comma. */
|
||||
const ipAllowListEntries = computed(() =>
|
||||
state.ipAllowListText
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
function humanizeDate(val) {
|
||||
if (!val) {
|
||||
return '---'
|
||||
}
|
||||
return Temporal.Instant.from(val).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
loading.show()
|
||||
try {
|
||||
const [config, status, api, security] = await Promise.all([
|
||||
API_CLIENT.get('system/scim').json(),
|
||||
API_CLIENT.get('system/scim/status').json(),
|
||||
API_CLIENT.get('system/api').json(),
|
||||
API_CLIENT.get('system/security').json()
|
||||
])
|
||||
state.enabled = config?.isEnabled === true
|
||||
state.config = { ...state.config, ...config }
|
||||
state.status = { ...state.status, ...status }
|
||||
state.apiEnabled = api?.isEnabled === true
|
||||
state.trustProxy = security?.trustProxy === true
|
||||
// -> Back into the box one per line; `config.ipAllowList` itself is not bound to anything
|
||||
state.ipAllowListText = (config?.ipAllowList ?? []).join('\n')
|
||||
/*
|
||||
Keeps the status light in the admin sidebar in step without another round trip, as the Metrics
|
||||
screen does for its own. Both flags, because the SCIM light reads them together: enabled with
|
||||
the API off is the orange state.
|
||||
*/
|
||||
adminStore.info.isScimEnabled = state.enabled
|
||||
adminStore.info.isApiEnabled = state.apiEnabled
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.scim.loadFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
loading.hide()
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await load()
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.scim.refreshSuccess')
|
||||
})
|
||||
}
|
||||
|
||||
async function copyTenantUrl() {
|
||||
await copyToClipboard(tenantUrl.value)
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.scim.tenantUrlCopied')
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.put('system/scim', {
|
||||
json: {
|
||||
deleteAction: state.config.deleteAction,
|
||||
emailSource: state.config.emailSource,
|
||||
allowGroupCreate: state.config.allowGroupCreate,
|
||||
rateLimitEnabled: state.config.rateLimitEnabled,
|
||||
rateLimitMax: state.config.rateLimitMax,
|
||||
rateLimitWindow: state.config.rateLimitWindow,
|
||||
rateLimitBan: state.config.rateLimitBan,
|
||||
ipAllowList: ipAllowListEntries.value
|
||||
}
|
||||
}).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occurred.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.scim.saveSuccess')
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.scim.saveFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function globalSwitch() {
|
||||
state.isToggleLoading = true
|
||||
const wanted = !state.enabled
|
||||
try {
|
||||
const resp = await API_CLIENT.put('system/scim', {
|
||||
json: { isEnabled: wanted }
|
||||
}).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occurred.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: wanted
|
||||
? t('admin.scim.toggleStateEnabledSuccess')
|
||||
: t('admin.scim.toggleStateDisabledSuccess')
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.scim.toggleStateFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.isToggleLoading = false
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
Loading…
Reference in new issue