mirror of https://github.com/requarks/wiki
parent
14e1efae41
commit
ff4a5bc6e6
@ -0,0 +1,137 @@
|
||||
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||
|
||||
/**
|
||||
* GitHub
|
||||
*
|
||||
* GitHub speaks OAuth 2.0 and not OpenID Connect: there is no ID token, and therefore nothing to
|
||||
* verify signatures on — the access token is exchanged over TLS and then spent against the API, which
|
||||
* answers who it belongs to. That is the whole protocol here, so this module is written with `fetch`
|
||||
* and no dependency. The parts a library would otherwise be trusted with — `state`, and keeping the
|
||||
* client secret off the browser — are done by the flow around it (`api/authentication.ts`).
|
||||
*
|
||||
* Two GitHub-specific things are worth the code:
|
||||
*
|
||||
* - the address comes from `/user/emails` rather than `/user`, because a profile's public email is
|
||||
* often empty and always unverified. Only a verified primary address is accepted;
|
||||
* - an organization can be required, checked against the membership API with the user's own token.
|
||||
*/
|
||||
export default class GitHubAuthentication {
|
||||
strategyId: string
|
||||
conf: Record<string, any>
|
||||
/** Set by `models/authentication.ts` right after construction. */
|
||||
module?: string
|
||||
|
||||
constructor(strategyId: string, conf: Record<string, any>) {
|
||||
this.strategyId = strategyId
|
||||
this.conf = conf
|
||||
}
|
||||
|
||||
/** Where a user signs in, and where the API lives — the two differ on Enterprise Server. */
|
||||
private get hosts(): { web: string; api: string } {
|
||||
const enterprise = (this.conf.enterpriseHost || '').trim().replace(/^https?:\/\//, '')
|
||||
return enterprise
|
||||
? { web: `https://${enterprise}`, api: `https://${enterprise}/api/v3` }
|
||||
: { web: 'https://github.com', api: 'https://api.github.com' }
|
||||
}
|
||||
|
||||
/** A GitHub API call as this user, with the headers GitHub asks every client to send. */
|
||||
private async api(path: string, accessToken: string): Promise<any> {
|
||||
const resp = await fetch(`${this.hosts.api}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Wiki.js'
|
||||
}
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`ERR_PROVIDER_REQUEST_FAILED`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async authorizationUrl({ redirectUri, state }: AuthFlow): Promise<string> {
|
||||
if (!this.conf.clientId || !this.conf.clientSecret) {
|
||||
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||
}
|
||||
const url = new URL(`${this.hosts.web}/login/oauth/authorize`)
|
||||
url.searchParams.set('client_id', this.conf.clientId)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
/*
|
||||
`user:email` is what makes the verified addresses readable; `read:org` is only asked for when an
|
||||
organization is being enforced, since a scope nobody needs is a scope nobody should be granting.
|
||||
*/
|
||||
url.searchParams.set(
|
||||
'scope',
|
||||
this.conf.allowedOrganization ? 'read:user user:email read:org' : 'read:user user:email'
|
||||
)
|
||||
url.searchParams.set('state', state)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async profile({ code, redirectUri }: AuthFlowCallback): Promise<ProviderProfile> {
|
||||
if (!code) {
|
||||
throw new Error('ERR_NO_AUTHORIZATION_CODE')
|
||||
}
|
||||
// -> `Accept: application/json`, or GitHub answers this one in form encoding
|
||||
const tokenResp = await fetch(`${this.hosts.web}/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Wiki.js'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.conf.clientId,
|
||||
client_secret: this.conf.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
code
|
||||
})
|
||||
})
|
||||
const token = (await tokenResp.json()) as Record<string, any>
|
||||
// -> GitHub reports a refused exchange as 200 with an `error` field, not as a status
|
||||
if (!tokenResp.ok || token.error || !token.access_token) {
|
||||
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
|
||||
}
|
||||
|
||||
const account = await this.api('/user', token.access_token)
|
||||
if (!account?.id) {
|
||||
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
||||
}
|
||||
|
||||
/*
|
||||
The primary verified address, which is the only one that says anything: `account.email` is
|
||||
whatever the profile shows publicly, is frequently null, and is never checked by GitHub.
|
||||
*/
|
||||
const emails: any[] = await this.api('/user/emails', token.access_token)
|
||||
const email = emails?.find((entry) => entry.primary && entry.verified)?.email
|
||||
if (!email) {
|
||||
throw new Error('ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER')
|
||||
}
|
||||
|
||||
if (this.conf.allowedOrganization) {
|
||||
const org = this.conf.allowedOrganization.trim()
|
||||
const resp = await fetch(
|
||||
`${this.hosts.api}/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(account.login)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Wiki.js'
|
||||
}
|
||||
}
|
||||
)
|
||||
// -> 204 is a member, 302 is "ask as somebody who can see", 404 is not a member
|
||||
if (resp.status !== 204) {
|
||||
throw new Error('ERR_LOGIN_RESTRICTED')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(account.id),
|
||||
email,
|
||||
name: account.name || account.login
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
key: github
|
||||
title: GitHub
|
||||
description: Sign in with a GitHub account, on github.com or a GitHub Enterprise Server.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/github.svg
|
||||
icon: /_assets/icons/ultraviolet-github.svg
|
||||
color: dark-4
|
||||
vendor: 'GitHub, Inc.'
|
||||
website: 'https://docs.github.com/en/apps/oauth-apps'
|
||||
isAvailable: true
|
||||
useForm: false
|
||||
usernameType: email
|
||||
props:
|
||||
clientId:
|
||||
type: String
|
||||
title: Client ID
|
||||
hint: From the OAuth app registered under Developer settings.
|
||||
icon: key
|
||||
order: 1
|
||||
clientSecret:
|
||||
type: String
|
||||
title: Client Secret
|
||||
hint: From the same OAuth app.
|
||||
icon: password
|
||||
sensitive: true
|
||||
order: 2
|
||||
enterpriseHost:
|
||||
type: String
|
||||
title: GitHub Enterprise Host
|
||||
hint: (optional) Hostname of a GitHub Enterprise Server, e.g. github.example.com. Leave empty for github.com.
|
||||
icon: server
|
||||
order: 3
|
||||
allowedOrganization:
|
||||
type: String
|
||||
title: Restrict to Organization
|
||||
hint: (optional) Login name of a GitHub organization. Only its members may sign in — which needs the account to be a public member, or the OAuth app to be approved by the organization.
|
||||
icon: user-groups
|
||||
order: 4
|
||||
refs:
|
||||
callbackUrl:
|
||||
title: Authorization Callback URL
|
||||
hint: Set this as the OAuth app's callback URL on GitHub.
|
||||
icon: back
|
||||
value: '{host}/_api/auth/{id}/callback'
|
||||
@ -0,0 +1,99 @@
|
||||
import * as client from 'openid-client'
|
||||
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||
|
||||
/** Google's issuer, from which every endpoint and signing key is discovered. */
|
||||
const ISSUER = 'https://accounts.google.com'
|
||||
|
||||
/**
|
||||
* Google
|
||||
*
|
||||
* Google is an OpenID Connect provider, so this is the generic flow with the issuer fixed and two
|
||||
* things Google specifically needs saying about:
|
||||
*
|
||||
* - a Workspace domain can be required, and the claim is checked HERE as well as asked for — `hd`
|
||||
* on the authorization request is a hint to the account chooser, not a promise about the answer;
|
||||
* - `email_verified` is honoured, because an account on this wiki is matched by email address and
|
||||
* an unverified one says nothing about who holds the mailbox.
|
||||
*
|
||||
* Written against `openid-client` rather than by hand for the reason the generic module is: the ID
|
||||
* token has to be verified, and a token nobody verified still logs somebody in.
|
||||
*/
|
||||
export default class GoogleAuthentication {
|
||||
strategyId: string
|
||||
conf: Record<string, any>
|
||||
/** Set by `models/authentication.ts` right after construction. */
|
||||
module?: string
|
||||
|
||||
private config: client.Configuration | null = null
|
||||
|
||||
constructor(strategyId: string, conf: Record<string, any>) {
|
||||
this.strategyId = strategyId
|
||||
this.conf = conf
|
||||
}
|
||||
|
||||
private async configuration(): Promise<client.Configuration> {
|
||||
if (this.config) {
|
||||
return this.config
|
||||
}
|
||||
if (!this.conf.clientId || !this.conf.clientSecret) {
|
||||
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||
}
|
||||
this.config = await client.discovery(
|
||||
new URL(ISSUER),
|
||||
this.conf.clientId,
|
||||
this.conf.clientSecret
|
||||
)
|
||||
return this.config
|
||||
}
|
||||
|
||||
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
|
||||
const config = await this.configuration()
|
||||
return client
|
||||
.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
// -> Which accounts the chooser offers. The answer is still checked below.
|
||||
...(this.conf.hostedDomain ? { hd: this.conf.hostedDomain } : {})
|
||||
})
|
||||
.toString()
|
||||
}
|
||||
|
||||
async profile({
|
||||
currentUrl,
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier
|
||||
}: AuthFlowCallback): Promise<ProviderProfile> {
|
||||
const config = await this.configuration()
|
||||
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
pkceCodeVerifier: codeVerifier
|
||||
})
|
||||
const claims = tokens.claims() as Record<string, any> | undefined
|
||||
if (!claims?.sub) {
|
||||
throw new Error('ERR_NO_ID_TOKEN')
|
||||
}
|
||||
|
||||
const email = claims.email
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||
}
|
||||
if (claims.email_verified === false && this.conf.allowUnverifiedEmail !== true) {
|
||||
throw new Error('ERR_EMAIL_NOT_VERIFIED')
|
||||
}
|
||||
if (this.conf.hostedDomain && claims.hd !== this.conf.hostedDomain) {
|
||||
throw new Error('ERR_LOGIN_RESTRICTED')
|
||||
}
|
||||
|
||||
return {
|
||||
id: claims.sub,
|
||||
email,
|
||||
name: (claims.name as string) || email
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
key: google
|
||||
title: Google
|
||||
description: Sign in with a Google account or a Google Workspace domain.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/google.svg
|
||||
icon: /_assets/icons/ultraviolet-google.svg
|
||||
color: red-6
|
||||
vendor: 'Google LLC'
|
||||
website: 'https://developers.google.com/identity/openid-connect/openid-connect'
|
||||
isAvailable: true
|
||||
useForm: false
|
||||
usernameType: email
|
||||
props:
|
||||
clientId:
|
||||
type: String
|
||||
title: Client ID
|
||||
hint: From the OAuth 2.0 Client ID created in the Google Cloud console.
|
||||
icon: key
|
||||
order: 1
|
||||
clientSecret:
|
||||
type: String
|
||||
title: Client Secret
|
||||
hint: From the same OAuth 2.0 Client ID.
|
||||
icon: password
|
||||
sensitive: true
|
||||
order: 2
|
||||
hostedDomain:
|
||||
type: String
|
||||
title: Restrict to Workspace Domain
|
||||
hint: (optional) A Workspace domain, e.g. example.com. Only accounts on it may sign in — checked here as well as asked for, since the parameter alone is a hint to Google rather than a guarantee.
|
||||
icon: geography
|
||||
order: 3
|
||||
allowUnverifiedEmail:
|
||||
type: Boolean
|
||||
title: Accept Unverified Addresses
|
||||
hint: Off by default. A Google account whose address is unverified proves nothing about the mailbox, and an account here is matched on the address.
|
||||
icon: received
|
||||
default: false
|
||||
order: 4
|
||||
refs:
|
||||
callbackUrl:
|
||||
title: Authorized Redirect URI
|
||||
hint: Add this to the OAuth client's authorized redirect URIs in the Google Cloud console.
|
||||
icon: back
|
||||
value: '{host}/_api/auth/{id}/callback'
|
||||
@ -0,0 +1,137 @@
|
||||
import * as client from 'openid-client'
|
||||
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||
|
||||
/**
|
||||
* Generic OpenID Connect / OAuth2
|
||||
*
|
||||
* The authorization code flow with PKCE, against any provider that speaks OpenID Connect. What makes
|
||||
* it OIDC rather than bare OAuth2 is the ID token: a signed statement of who signed in, which is
|
||||
* verified here against the provider's published keys — issuer, audience, nonce and signature — before
|
||||
* anything is believed about the person behind it.
|
||||
*
|
||||
* That verification is why this goes through `openid-client` rather than a handful of `fetch` calls.
|
||||
* The requests themselves are trivial; the checks around them are where a mistake is silent, because
|
||||
* a token that is never verified still logs somebody in.
|
||||
*/
|
||||
export default class OidcAuthentication {
|
||||
strategyId: string
|
||||
conf: Record<string, any>
|
||||
/** Set by `models/authentication.ts` right after construction. */
|
||||
module?: string
|
||||
|
||||
/**
|
||||
* The provider as `openid-client` sees it. Built once and kept: with discovery on it is a network
|
||||
* round trip, and it is the same answer for every login until the strategy is saved again.
|
||||
*/
|
||||
private config: client.Configuration | null = null
|
||||
|
||||
constructor(strategyId: string, conf: Record<string, any>) {
|
||||
this.strategyId = strategyId
|
||||
this.conf = conf
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider's metadata.
|
||||
*
|
||||
* Discovery is the path worth taking: the endpoints AND the signing keys come from the issuer
|
||||
* itself, so a provider rotating either is followed without an administrator editing anything. The
|
||||
* manual path exists for providers that publish no discovery document, and needs the JWKS URL for
|
||||
* the same reason — without keys there is nothing to check the ID token against.
|
||||
*/
|
||||
private async configuration(): Promise<client.Configuration> {
|
||||
if (this.config) {
|
||||
return this.config
|
||||
}
|
||||
const { clientId, clientSecret, issuer } = this.conf
|
||||
if (!clientId || !clientSecret || !issuer) {
|
||||
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||
}
|
||||
if (this.conf.useDiscovery !== false) {
|
||||
this.config = await client.discovery(new URL(issuer), clientId, clientSecret)
|
||||
} else {
|
||||
if (!this.conf.authorizationURL || !this.conf.tokenURL || !this.conf.jwksURL) {
|
||||
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||
}
|
||||
this.config = new client.Configuration(
|
||||
{
|
||||
issuer,
|
||||
authorization_endpoint: this.conf.authorizationURL,
|
||||
token_endpoint: this.conf.tokenURL,
|
||||
userinfo_endpoint: this.conf.userInfoURL || undefined,
|
||||
jwks_uri: this.conf.jwksURL
|
||||
},
|
||||
clientId,
|
||||
clientSecret
|
||||
)
|
||||
}
|
||||
return this.config
|
||||
}
|
||||
|
||||
/** Where to send the browser to sign in. */
|
||||
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
|
||||
const config = await this.configuration()
|
||||
return client
|
||||
.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: this.conf.scopes || 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||
code_challenge_method: 'S256'
|
||||
})
|
||||
.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the code the provider sent back into who signed in.
|
||||
*
|
||||
* `authorizationCodeGrant` is what does the checking: it refuses a response whose state does not
|
||||
* match the one this flow started with, exchanges the code with the PKCE verifier, and validates
|
||||
* the ID token's signature, issuer, audience and nonce. Everything after it is reading claims.
|
||||
*/
|
||||
async profile({
|
||||
currentUrl,
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier
|
||||
}: AuthFlowCallback): Promise<ProviderProfile> {
|
||||
const config = await this.configuration()
|
||||
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
pkceCodeVerifier: codeVerifier
|
||||
})
|
||||
const claims = tokens.claims()
|
||||
if (!claims?.sub) {
|
||||
throw new Error('ERR_NO_ID_TOKEN')
|
||||
}
|
||||
|
||||
/*
|
||||
The userinfo endpoint is consulted when the provider has one, because a provider is free to keep
|
||||
claims out of the ID token and behind it — several put the email address there only. Its answer
|
||||
is merged over the token's, and `fetchUserInfo` checks that it is about the same subject.
|
||||
*/
|
||||
let info: Record<string, any> = claims
|
||||
if (config.serverMetadata().userinfo_endpoint) {
|
||||
info = {
|
||||
...claims,
|
||||
...(await client.fetchUserInfo(config, tokens.access_token, claims.sub))
|
||||
}
|
||||
}
|
||||
|
||||
const email = info[this.conf.emailClaim || 'email']
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||
}
|
||||
return {
|
||||
id: claims.sub,
|
||||
email,
|
||||
name: (info[this.conf.displayNameClaim || 'name'] as string) || email
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a logout should continue, so that the session at the provider ends too. */
|
||||
logoutUrl(): string | null {
|
||||
return this.conf.logoutURL || null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
key: oidc
|
||||
title: Generic OpenID Connect / OAuth2
|
||||
description: OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/oidc.svg
|
||||
icon: /_assets/icons/ultraviolet-openid.svg
|
||||
color: blue-grey-8
|
||||
vendor: 'OpenID Foundation'
|
||||
website: 'https://openid.net/connect/'
|
||||
isAvailable: true
|
||||
useForm: false
|
||||
usernameType: email
|
||||
props:
|
||||
clientId:
|
||||
type: String
|
||||
title: Client ID
|
||||
hint: Application Client ID, as the provider issued it.
|
||||
icon: key
|
||||
order: 1
|
||||
clientSecret:
|
||||
type: String
|
||||
title: Client Secret
|
||||
hint: Application Client Secret, as the provider issued it.
|
||||
icon: password
|
||||
sensitive: true
|
||||
order: 2
|
||||
issuer:
|
||||
type: String
|
||||
title: Issuer
|
||||
hint: The provider's issuer URL, e.g. https://id.example.com. Everything else is discovered from it.
|
||||
icon: internet
|
||||
order: 3
|
||||
useDiscovery:
|
||||
type: Boolean
|
||||
title: Use Discovery
|
||||
hint: Read the endpoints and signing keys from the issuer's /.well-known/openid-configuration. Turn off only for a provider that does not publish one, and fill in the endpoints below.
|
||||
icon: rescan-document
|
||||
default: true
|
||||
order: 4
|
||||
authorizationURL:
|
||||
type: String
|
||||
title: Authorization Endpoint URL
|
||||
hint: Ignored while discovery is on.
|
||||
icon: enter
|
||||
order: 5
|
||||
tokenURL:
|
||||
type: String
|
||||
title: Token Endpoint URL
|
||||
hint: Ignored while discovery is on.
|
||||
icon: exit
|
||||
order: 6
|
||||
userInfoURL:
|
||||
type: String
|
||||
title: User Info Endpoint URL
|
||||
hint: Ignored while discovery is on. Optional even without it — the ID token alone can carry everything needed.
|
||||
icon: contact
|
||||
order: 7
|
||||
jwksURL:
|
||||
type: String
|
||||
title: JSON Web Key Set URL
|
||||
hint: Ignored while discovery is on. Where the keys that signed the ID token are published; without it the ID token cannot be verified and logins are refused.
|
||||
icon: fingerprint-scan
|
||||
order: 8
|
||||
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: 9
|
||||
emailClaim:
|
||||
type: String
|
||||
title: Email Claim
|
||||
hint: Which claim carries the email address.
|
||||
icon: envelope
|
||||
default: email
|
||||
order: 10
|
||||
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: 11
|
||||
logoutURL:
|
||||
type: String
|
||||
title: Logout URL
|
||||
hint: (optional) Where to send a user after logging out, so that the session at the provider ends too.
|
||||
icon: exit
|
||||
order: 12
|
||||
refs:
|
||||
callbackUrl:
|
||||
title: Authorization Callback URL
|
||||
hint: Register this as the redirect URI at the provider. It is the same for every provider.
|
||||
icon: back
|
||||
value: '{host}/_api/auth/{id}/callback'
|
||||
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" max-width="450px" @hide="onDialogHide">
|
||||
<w-card style="min-width: 350px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-delete-bin.svg" size="sm" class="mr-2" />
|
||||
<span>{{ t(`admin.users.deleteConfirmTitle`) }}</span>
|
||||
</w-card-section>
|
||||
<w-card-section>
|
||||
<div class="text-body2">
|
||||
<i18n-t keypath="admin.users.deleteConfirmText">
|
||||
<template #username>
|
||||
<strong>{{ props.user.name }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<!--
|
||||
Said before the attempt rather than only when it fails: a user who has written anything
|
||||
cannot be deleted at all, and finding that out from an error after confirming is finding it
|
||||
out too late to have chosen deactivation instead.
|
||||
-->
|
||||
<div class="text-body2 mt-4">{{ t(`admin.users.deleteConfirmForeignNotice`) }}</div>
|
||||
<div class="text-body2 mt-4">
|
||||
<strong class="text-negative">{{ t(`admin.users.deleteHint`) }}</strong>
|
||||
</div>
|
||||
</w-card-section>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.delete`)"
|
||||
color="negative"
|
||||
padding="xs md"
|
||||
:loading="state.isDeleting"
|
||||
@click="confirm" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
isDeleting: false
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
async function confirm() {
|
||||
state.isDeleting = true
|
||||
try {
|
||||
const resp = await API_CLIENT.delete(`users/${props.user.id}`)
|
||||
if (!resp?.ok) {
|
||||
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.users.deleteSuccess', { username: props.user.name })
|
||||
})
|
||||
onDialogOK()
|
||||
} catch (err) {
|
||||
/*
|
||||
ky throws for statuses above 400, and this endpoint has several things to say through one: the
|
||||
account owns pages, it is the last root administrator, it is a system user, it is the caller's
|
||||
own. The reason is in the body, so the dialog stays open with it rather than closing on a
|
||||
failure it did not report.
|
||||
*/
|
||||
const apiMessage = await err.response
|
||||
?.json()
|
||||
.then((b) => b?.message)
|
||||
.catch(() => null)
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: apiMessage || err.message
|
||||
})
|
||||
}
|
||||
state.isDeleting = false
|
||||
}
|
||||
</script>
|
||||
Loading…
Reference in new issue