mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 15:14:56 -04:00
Add one-click login if possible (#1043)
This commit is contained in:
parent
a6c80ac1f0
commit
6d2dde477c
26 changed files with 216 additions and 53 deletions
|
@ -85,7 +85,8 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
- The dark-mode is also applied to the read-only-view and can be toggled from there.
|
- The dark-mode is also applied to the read-only-view and can be toggled from there.
|
||||||
- Access tokens for the CLI and 3rd-party-clients can be managed in the user profile.
|
- Access tokens for the CLI and 3rd-party-clients can be managed in the user profile.
|
||||||
- Change editor font to "Fira Code"
|
- Change editor font to "Fira Code"
|
||||||
- Note tags can be set as yaml-array in frontmatter
|
- Note tags can be set as yaml-array in frontmatter.
|
||||||
|
- If only one external login provider is configured, the sign-in button will directly link to it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Autocompletion', () => {
|
describe('Autocompletion', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
cy.get('.CodeMirror')
|
cy.get('.CodeMirror')
|
||||||
.click()
|
.click()
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { banner } from '../support/config'
|
||||||
|
|
||||||
describe('Banner', () => {
|
describe('Banner', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
expect(localStorage.getItem('bannerTimeStamp')).to.be.null
|
expect(localStorage.getItem('bannerTimeStamp')).to.be.null
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Diagram codeblock ', () => {
|
describe('Diagram codeblock ', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { branding } from '../support/config'
|
||||||
const title = 'This is a test title'
|
const title = 'This is a test title'
|
||||||
describe('Document Title', () => {
|
describe('Document Title', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
cy.get('.btn.active.btn-outline-secondary > i.fa-columns')
|
cy.get('.btn.active.btn-outline-secondary > i.fa-columns')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
|
|
|
@ -5,6 +5,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('Editor mode from URL parameter is used', () => {
|
describe('Editor mode from URL parameter is used', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
|
})
|
||||||
|
|
||||||
it('mode view', () => {
|
it('mode view', () => {
|
||||||
cy.visitTestEditor('view')
|
cy.visitTestEditor('view')
|
||||||
cy.get('.splitter.left')
|
cy.get('.splitter.left')
|
||||||
|
|
|
@ -9,6 +9,7 @@ describe('Export', () => {
|
||||||
const testContent = `---\ntitle: ${ testTitle }\n---\nThis is some test content`
|
const testContent = `---\ntitle: ${ testTitle }\n---\nThis is some test content`
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
cy.codemirrorFill(testContent)
|
cy.codemirrorFill(testContent)
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,6 +8,7 @@ const imageUrl = 'http://example.com/non-existing.png'
|
||||||
|
|
||||||
describe('File upload', () => {
|
describe('File upload', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Help Dialog', () => {
|
describe('Help Dialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ const findHljsCodeBlock = () => {
|
||||||
|
|
||||||
describe('Code', () => {
|
describe('Code', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('History', () => {
|
describe('History', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visit('/history')
|
cy.visit('/history')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Import markdown file', () => {
|
describe('Import markdown file', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
describe('Intro page', () => {
|
describe('Intro page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.intercept('/intro.md', 'test content')
|
cy.intercept('/intro.md', 'test content')
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { languages } from '../fixtures/languages'
|
||||||
|
|
||||||
describe('Languages', () => {
|
describe('Languages', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import '../support/index'
|
||||||
|
|
||||||
describe('Links Intro', () => {
|
describe('Links Intro', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -53,13 +54,6 @@ describe('Links Intro', () => {
|
||||||
cy.url()
|
cy.url()
|
||||||
.should('include', '/new')
|
.should('include', '/new')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Sign In', () => {
|
|
||||||
cy.get('.btn-success.btn-sm')
|
|
||||||
.click()
|
|
||||||
cy.url()
|
|
||||||
.should('include', '/login')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Menu Buttons logged in', () => {
|
describe('Menu Buttons logged in', () => {
|
||||||
|
@ -83,7 +77,7 @@ describe('Links Intro', () => {
|
||||||
.should('include', '/features')
|
.should('include', '/features')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Features', () => {
|
it('Profile', () => {
|
||||||
cy.get('a.dropdown-item > i.fa-user')
|
cy.get('a.dropdown-item > i.fa-user')
|
||||||
.click()
|
.click()
|
||||||
cy.url()
|
cy.url()
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Link gets replaced with embedding: ', () => {
|
describe('Link gets replaced with embedding: ', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ describe('The status bar text length info', () => {
|
||||||
const tooMuchTestContent = `${ dangerTestContent }a`
|
const tooMuchTestContent = `${ dangerTestContent }a`
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('profile page', () => {
|
describe('profile page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.intercept({
|
cy.intercept({
|
||||||
url: '/api/v2/tokens',
|
url: '/api/v2/tokens',
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Quote extra tags', function () {
|
describe('Quote extra tags', function () {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('Short code gets replaced or rendered: ', () => {
|
describe('Short code gets replaced or rendered: ', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
114
cypress/integration/signInButton.spec.ts
Normal file
114
cypress/integration/signInButton.spec.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
const authProvidersDisabled = {
|
||||||
|
facebook: false,
|
||||||
|
github: false,
|
||||||
|
twitter: false,
|
||||||
|
gitlab: false,
|
||||||
|
dropbox: false,
|
||||||
|
ldap: false,
|
||||||
|
google: false,
|
||||||
|
saml: false,
|
||||||
|
oauth2: false,
|
||||||
|
internal: false,
|
||||||
|
openid: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: Partial<typeof authProvidersDisabled>) => {
|
||||||
|
cy.loadConfig({
|
||||||
|
authProviders: {
|
||||||
|
...authProvidersDisabled,
|
||||||
|
...enabledProviders
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cy.visit('/')
|
||||||
|
cy.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('When logged-in, ', () => {
|
||||||
|
it('sign-in button is hidden', () => {
|
||||||
|
cy.loadConfig()
|
||||||
|
cy.visit('/')
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When logged-out ', () => {
|
||||||
|
describe('and no auth-provider is enabled, ', () => {
|
||||||
|
it('sign-in button is hidden', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('and an interactive auth-provider is enabled, ', () => {
|
||||||
|
it('sign-in button points to login route: internal', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {
|
||||||
|
internal: true
|
||||||
|
})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('be.visible')
|
||||||
|
.should('have.attr', 'href', '/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sign-in button points to login route: ldap', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {
|
||||||
|
ldap: true
|
||||||
|
})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('be.visible')
|
||||||
|
.should('have.attr', 'href', '/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sign-in button points to login route: openid', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {
|
||||||
|
openid: true
|
||||||
|
})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('be.visible')
|
||||||
|
.should('have.attr', 'href', '/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('and only one one-click auth-provider is enabled, ', () => {
|
||||||
|
it('sign-in button points to auth-provider', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {
|
||||||
|
saml: true
|
||||||
|
})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('be.visible')
|
||||||
|
// The absolute URL is used because it is defined as API base URL absolute.
|
||||||
|
.should('have.attr', 'href', 'http://127.0.0.1:3001/api/v2/auth/saml')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('and multiple one-click auth-providers are enabled, ', () => {
|
||||||
|
it('sign-in button points to login route', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {
|
||||||
|
saml: true,
|
||||||
|
github: true
|
||||||
|
})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('be.visible')
|
||||||
|
.should('have.attr', 'href', '/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('and one-click- as well as interactive auth-providers are enabled, ', () => {
|
||||||
|
it('sign-in button points to login route', () => {
|
||||||
|
initLoggedOutTestWithCustomAuthProviders(cy, {
|
||||||
|
saml: true,
|
||||||
|
internal: true
|
||||||
|
})
|
||||||
|
cy.get('[data-cy=sign-in-button]')
|
||||||
|
.should('be.visible')
|
||||||
|
.should('have.attr', 'href', '/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -9,6 +9,7 @@ describe('Toolbar Buttons', () => {
|
||||||
const testLink = 'http://hedgedoc.org'
|
const testLink = 'http://hedgedoc.org'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
|
|
||||||
cy.get('.CodeMirror')
|
cy.get('.CodeMirror')
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
describe('YAML Array for deprecated syntax of document tags in frontmatter', () => {
|
describe('YAML Array for deprecated syntax of document tags in frontmatter', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
cy.loadConfig()
|
||||||
cy.visitTestEditor()
|
cy.visitTestEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,12 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
loadConfig(): Chainable<Window>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const banner = {
|
export const banner = {
|
||||||
text: 'This is the mock banner call',
|
text: 'This is the mock banner call',
|
||||||
timestamp: '2020-05-22T20:46:08.962Z'
|
timestamp: '2020-05-22T20:46:08.962Z'
|
||||||
|
@ -14,12 +20,7 @@ export const branding = {
|
||||||
logo: '/img/acme.png'
|
logo: '/img/acme.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
export const authProviders = {
|
||||||
cy.intercept('/api/v2/config', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
allowAnonymous: true,
|
|
||||||
authProviders: {
|
|
||||||
facebook: true,
|
facebook: true,
|
||||||
github: true,
|
github: true,
|
||||||
twitter: true,
|
twitter: true,
|
||||||
|
@ -29,9 +30,13 @@ beforeEach(() => {
|
||||||
google: true,
|
google: true,
|
||||||
saml: true,
|
saml: true,
|
||||||
oauth2: true,
|
oauth2: true,
|
||||||
email: true,
|
internal: true,
|
||||||
openid: true
|
openid: true
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
allowAnonymous: true,
|
||||||
|
authProviders: authProviders,
|
||||||
branding: branding,
|
branding: branding,
|
||||||
banner: banner,
|
banner: banner,
|
||||||
customAuthNames: {
|
customAuthNames: {
|
||||||
|
@ -56,5 +61,13 @@ beforeEach(() => {
|
||||||
'rendererOrigin': 'http://127.0.0.1:3001'
|
'rendererOrigin': 'http://127.0.0.1:3001'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
|
||||||
|
return cy.intercept('/api/v2/config', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
...config,
|
||||||
|
...additionalConfig
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
import { RegisterError } from '../../components/register-page/register-page'
|
import { RegisterError } from '../../components/register-page/register-page'
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||||
|
|
||||||
|
export const INTERACTIVE_LOGIN_METHODS = ['internal', 'ldap', 'openid']
|
||||||
|
|
||||||
export const doInternalLogin = async (username: string, password: string): Promise<void> => {
|
export const doInternalLogin = async (username: string, password: string): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + '/auth/internal', {
|
const response = await fetch(getApiUrl() + '/auth/internal', {
|
||||||
...defaultFetchConfig,
|
...defaultFetchConfig,
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import equal from 'fast-deep-equal'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { ButtonProps } from 'react-bootstrap/Button'
|
import { ButtonProps } from 'react-bootstrap/Button'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
@ -12,16 +13,31 @@ import { useSelector } from 'react-redux'
|
||||||
import { LinkContainer } from 'react-router-bootstrap'
|
import { LinkContainer } from 'react-router-bootstrap'
|
||||||
import { ApplicationState } from '../../../redux'
|
import { ApplicationState } from '../../../redux'
|
||||||
import { ShowIf } from '../../common/show-if/show-if'
|
import { ShowIf } from '../../common/show-if/show-if'
|
||||||
|
import { getApiUrl } from '../../../api/utils'
|
||||||
|
import { INTERACTIVE_LOGIN_METHODS } from '../../../api/auth'
|
||||||
|
|
||||||
export type SignInButtonProps = Omit<ButtonProps, 'href'>
|
export type SignInButtonProps = Omit<ButtonProps, 'href'>
|
||||||
|
|
||||||
export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => {
|
export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const anyAuthProviderActive = useSelector((state: ApplicationState) => Object.values(state.config.authProviders)
|
const authProviders = useSelector((state: ApplicationState) => state.config.authProviders, equal)
|
||||||
.includes(true))
|
const authEnabled = useMemo(() => Object.values(authProviders).includes(true), [authProviders])
|
||||||
|
|
||||||
|
const loginLink = useMemo(() => {
|
||||||
|
const activeProviders = Object.entries(authProviders)
|
||||||
|
.filter((entry: [string, boolean]) => entry[1])
|
||||||
|
.map(entry => entry[0])
|
||||||
|
const activeOneClickProviders = activeProviders.filter(entry => !INTERACTIVE_LOGIN_METHODS.includes(entry))
|
||||||
|
|
||||||
|
if (activeProviders.length === 1 && activeOneClickProviders.length === 1) {
|
||||||
|
return `${ getApiUrl() }/auth/${ activeOneClickProviders[0] }`
|
||||||
|
}
|
||||||
|
return '/login'
|
||||||
|
}, [authProviders])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowIf condition={ anyAuthProviderActive }>
|
<ShowIf condition={ authEnabled }>
|
||||||
<LinkContainer to="/login" title={ t('login.signIn') }>
|
<LinkContainer to={ loginLink } title={ t('login.signIn') }>
|
||||||
<Button
|
<Button
|
||||||
data-cy={ 'sign-in-button' }
|
data-cy={ 'sign-in-button' }
|
||||||
variant={ variant || 'success' }
|
variant={ variant || 'success' }
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue