fix: improve login UI + error/success messages

scarlett
NGPixel 2 weeks ago
parent ccb2b702fe
commit 6053cb982f
No known key found for this signature in database

@ -1344,6 +1344,7 @@
"auth.forgotPasswordLoading": "Requesting password reset...",
"auth.forgotPasswordSubtitle": "Enter your email address to receive the instructions to reset your password:",
"auth.forgotPasswordSuccess": "Check your emails for password reset instructions!",
"auth.forgotPasswordSuccessTitle": "Password reset requested",
"auth.forgotPasswordTitle": "Forgot your password",
"auth.genericError": "Authentication is unavailable.",
"auth.invalidEmail": "Email address is invalid.",
@ -1388,7 +1389,7 @@
"auth.tfaSetupSuccess": "2FA enabled successfully on your account.",
"auth.tfaSetupTitle": "Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.",
"auth.tfaSetupVerifying": "Verifying...",
"auth.verifyEmail.instructions": "Confirm that this email address is yours to activate your account:",
"auth.verifyEmail.instructions": "Finish activating your account by clicking the button below to confirm:",
"auth.verifyEmail.loading": "Confirming your email address...",
"auth.verifyEmail.proceed": "Confirm Email Address",
"auth.verifyEmail.success": "Your email address has been confirmed. You can now log in.",

@ -96,7 +96,7 @@ class Settings {
value: {
senderName: '',
senderEmail: '',
defaultBaseURL: 'https://wiki.example.com',
defaultBaseURL: '',
host: '',
port: 465,
name: '',

@ -1,5 +1,19 @@
<template>
<div>
<!--
What went wrong, above whichever screen is showing.
Above rather than beside the field it came from, because most of these are not about one field:
a refused password, an expired reset token, a provider that failed somewhere else entirely. And
here rather than in a toast at the top of the window, which is five seconds of small text a long
way from the form being read a login that quietly does nothing is how a wrong password looks
when the only thing that said so has already gone.
-->
<w-banner v-if="state.errorMessage" class="auth-error" role="alert">
<template #avatar><w-icon name="mdi:alert" size="24px" /></template>
<div class="auth-error-message">{{ state.errorMessage }}</div>
<div class="auth-error-caption" v-if="state.errorCaption">{{ state.errorCaption }}</div>
</w-banner>
<!--
Nothing until the strategies are known.
@ -18,9 +32,9 @@
<!-- LOGIN SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `login`">
<p v-if="formStrategies.length < 2">{{ t('auth.enterCredentials') }}</p>
<p class="auth-subtitle" v-if="formStrategies.length < 2">{{ t('auth.enterCredentials') }}</p>
<template v-else>
<p>{{ t('auth.selectAuthProvider') }}</p>
<p class="auth-subtitle">{{ t('auth.selectAuthProvider') }}</p>
<div class="auth-strategies mb-4">
<w-btn
v-for="str of formStrategies"
@ -154,7 +168,7 @@
<!-- FORGOT PASSWORD SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `forgot`">
<p>{{ t('auth.forgotPasswordSubtitle') }}</p>
<p class="auth-subtitle">{{ t('auth.forgotPasswordSubtitle') }}</p>
<w-form ref="forgotForm" @submit="forgotPassword">
<w-input
ref="forgotEmailIpt"
@ -196,7 +210,7 @@
reader with a link that has already been used.
-->
<template v-else-if="state.screen === `verifyEmail`">
<p>{{ t('auth.verifyEmail.instructions') }}</p>
<p class="auth-subtitle">{{ t('auth.verifyEmail.instructions') }}</p>
<w-btn
class="w-full mt-2"
push
@ -219,7 +233,7 @@
<!-- RESET PASSWORD SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `resetPwd`">
<p>{{ t('auth.resetPwd.instructions') }}</p>
<p class="auth-subtitle">{{ t('auth.resetPwd.instructions') }}</p>
<w-form ref="resetPwdForm" @submit="resetPassword">
<w-input
v-model="state.newPassword"
@ -274,7 +288,7 @@
<!-- REGISTER SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `register`">
<p>{{ t('auth.registerSubTitle') }}</p>
<p class="auth-subtitle">{{ t('auth.registerSubTitle') }}</p>
<w-form ref="registerForm" @submit="register">
<w-input
ref="registerNameIpt"
@ -352,7 +366,9 @@
<!-- CHANGE PASSWORD SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `changePwd`">
<p v-if="state.continuationToken">{{ t('auth.changePwd.instructions') }}</p>
<p class="auth-subtitle" v-if="state.continuationToken">
{{ t('auth.changePwd.instructions') }}
</p>
<w-form ref="changePwdForm" @submit="changePwd">
<w-input
v-if="!state.continuationToken"
@ -412,7 +428,7 @@
<!-- TFA SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `tfa`">
<p>{{ t('auth.tfa.subtitle') }}</p>
<p class="auth-subtitle">{{ t('auth.tfa.subtitle') }}</p>
<v-otp-input
v-model:value="state.securityCode"
:num-inputs="6"
@ -434,12 +450,12 @@
<!-- TFA SETUP SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `tfasetup`">
<p>{{ t('auth.tfaSetupTitle') }}</p>
<p>{{ t('auth.tfaSetupInstrFirst') }}</p>
<p class="auth-subtitle">{{ t('auth.tfaSetupTitle') }}</p>
<p class="auth-subtitle">{{ t('auth.tfaSetupInstrFirst') }}</p>
<div style="justify-content: center; display: flex">
<div v-html="state.tfaQRImage" style="width: 200px" />
</div>
<p class="mt-2">{{ t('auth.tfaSetupInstrSecond') }}</p>
<p class="auth-subtitle">{{ t('auth.tfaSetupInstrSecond') }}</p>
<v-otp-input
v-model:value="state.securityCode"
:num-inputs="6"
@ -456,6 +472,31 @@
icon="la:sign-in-alt"
@click="finishSetupTFA" />
</template>
<!-- ----------------------------------------------------- -->
<!-- SUCCESS SCREEN -->
<!-- ----------------------------------------------------- -->
<!--
Where a flow that has nothing left to do ends. A mail sent, a password changed, an address
confirmed: that is the whole answer to what the reader just pressed, and said in a toast it is
four lines at the top of the window, gone in five seconds, nowhere near the form they were
looking at. Here it takes the panel over, and the way on is the link under it.
-->
<template v-else-if="state.screen === `success`">
<div class="auth-success" role="status">
<w-icon name="mdi:check-circle" color="positive" size="5rem" />
<h3 class="auth-success-message">{{ state.successMessage }}</h3>
<p class="auth-success-caption" v-if="state.successCaption">{{ state.successCaption }}</p>
</div>
<w-separator class="my-4" />
<w-btn
class="acrylic-btn w-full"
flat
color="primary"
:label="t(`auth.switchToLogin.link`)"
no-caps
icon="la:arrow-circle-left"
@click="switchTo(`login`)" />
</template>
</div>
</template>
@ -510,6 +551,12 @@ const state = reactive({
verifyToken: '',
/** Whether the strategies have been fetched — settled either way, so a failure still draws. */
strategiesLoaded: false,
/** What the success screen says. Set by `showSuccess()`, never read anywhere else. */
successMessage: '',
successCaption: '',
/** What the error box above the panel says, if anything. Set by `showError()`. */
errorMessage: '',
errorCaption: '',
isTFAShown: false,
isTFASetupShown: false,
tfaQRImage: ''
@ -647,6 +694,48 @@ function switchTo(screen) {
throw new Error('Invalid Screen')
}
}
/*
A screen change is a fresh start, so whatever the last one was complaining about goes with it.
The two flows that mean to carry a message across a change a 2FA token the server has discarded,
a confirmation link that was refused move first and call `showError()` after, which is the whole
reason this clears here rather than in `showError()`'s callers.
*/
clearError()
}
/**
* Say what went wrong, in the box above the panel.
*
* @param message What failed
* @param caption The underlying reason, where the message is a summary of it
*/
function showError(message, caption = '') {
state.errorMessage = message
state.errorCaption = caption
}
/** Take the error box away. Called as each action starts, so nothing answers for the previous one. */
function clearError() {
state.errorMessage = ''
state.errorCaption = ''
}
/**
* End a flow on the success screen.
*
* Every flow that gets here is finished there is nothing to continue and no session to carry on
* with so the screen's own way out is always the link back to the login form, and this only has to
* say what happened.
*
* @param message What was accomplished
* @param caption What the reader does next, where that is not the login form itself
*/
function showSuccess(message, caption = '') {
state.successMessage = message
state.successCaption = caption
state.screen = 'success'
// -> Not via `switchTo()`, so the clearing that comes with a screen change has to be said here
clearError()
}
async function fetchStrategies(showAll = false) {
@ -661,11 +750,7 @@ async function fetchStrategies(showAll = false) {
} catch (err) {
// -> Said out loud rather than left as an unhandled rejection: what the reader is looking at is
// a login form with no strategy behind it, and a submit that cannot go anywhere
notify({
type: 'negative',
message: t('auth.genericError'),
caption: apiErrorMessage(err)
})
showError(t('auth.genericError'), apiErrorMessage(err))
} finally {
// -> In a `finally`, so a wiki whose strategies could not be fetched still shows its form rather
// than spinning for ever
@ -727,17 +812,20 @@ async function handleLoginResponse(resp) {
*/
case 'verifyEmail': {
loading.hide()
notify({
type: 'positive',
message: t('auth.registerSuccess'),
caption: t('auth.registerCheckEmail')
})
switchTo('login')
showSuccess(t('auth.registerSuccess'), t('auth.registerCheckEmail'))
break
}
case 'redirect': {
/*
No delay, unlike every other use of the overlay: this one is not covering a wait, it IS the
confirmation the reader is leaving for another page a second from now and this line is the
only thing that will have said the login worked. Left on the default 500ms it would appear
halfway through that second, or not at all when the response was already slow enough for the
overlay to be up.
*/
loading.show({
message: t('auth.loginSuccess')
message: t('auth.loginSuccess'),
delay: 0
})
setTimeout(() => {
const loginRedirect = Cookies.get('loginRedirect')
@ -757,10 +845,7 @@ async function handleLoginResponse(resp) {
}
default: {
loading.hide()
notify({
type: 'negative',
message: 'Unexpected Authentication Response'
})
showError('Unexpected Authentication Response')
}
}
}
@ -769,6 +854,7 @@ async function handleLoginResponse(resp) {
* LOGIN
*/
async function login() {
clearError()
loading.show({
message: t('auth.signingIn')
})
@ -794,10 +880,7 @@ async function login() {
} catch (err) {
console.warn(err)
loading.hide()
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -805,6 +888,7 @@ async function login() {
* LOGIN WITH PASSKEY
*/
async function loginWithPasskey() {
clearError()
loading.show({
message: t('auth.signingIn')
})
@ -834,10 +918,7 @@ async function loginWithPasskey() {
if (err.name === 'NotAllowedError' || err.name === 'AbortError') {
return
}
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -845,6 +926,7 @@ async function loginWithPasskey() {
* FORGOT PASSWORD
*/
async function forgotPassword() {
clearError()
loading.show({
message: t('auth.forgotPasswordLoading')
})
@ -869,17 +951,10 @@ async function forgotPassword() {
this has to keep: saying "no such account" on a public form is how a wiki's member list gets
read off it one address at a time.
*/
notify({
type: 'positive',
message: t('auth.forgotPasswordSuccess')
})
switchTo('login')
showSuccess(t('auth.forgotPasswordSuccessTitle'), t('auth.forgotPasswordSuccess'))
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -887,10 +962,11 @@ async function forgotPassword() {
* RESET PASSWORD
*
* The other end of the link in a reset email. Nothing signs the user in here the token stands for
* the mailbox rather than for a half-finished login so what follows a successful reset is the login
* screen, with the new password to type into it.
* the mailbox rather than for a half-finished login so what follows a successful reset is the
* success screen, and the login form is a link away from it, with the new password to type into it.
*/
async function resetPassword() {
clearError()
loading.show({
message: t('auth.changePwd.loading')
})
@ -914,17 +990,10 @@ async function resetPassword() {
state.newPassword = ''
state.newPasswordVerify = ''
clearQueryParams(['reset'])
notify({
type: 'positive',
message: t('auth.resetPwd.success')
})
switchTo('login')
showSuccess(t('auth.resetPwd.success'))
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -932,6 +1001,7 @@ async function resetPassword() {
* REGISTER
*/
async function register() {
clearError()
try {
const isFormValid = await registerForm.value.validate(true)
if (!isFormValid) {
@ -960,10 +1030,7 @@ async function register() {
await handleLoginResponse(resp)
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -971,6 +1038,7 @@ async function register() {
* CHANGE PASSWORD
*/
async function changePwd() {
clearError()
try {
const isFormValid = await changePwdForm.value.validate(true)
if (!isFormValid) {
@ -995,10 +1063,7 @@ async function changePwd() {
throw new Error(resp.message || 'ERR_CHANGE_PASSWORD_FAILED')
}
} catch (err) {
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -1039,19 +1104,18 @@ async function submitTFA(setup) {
async function handleTFAError(err) {
const code = apiErrorMessage(err)
loading.hide()
notify({
type: 'negative',
message: localizeError(code, t)
})
if (code === 'ERR_INVALID_VALIDATION_TOKEN' || code === 'ERR_EXPIRED_VALIDATION_TOKEN') {
state.continuationToken = ''
state.securityCode = ''
state.password = ''
switchTo('login')
}
// -> After the screen change above, which clears the box this is about to fill
showError(localizeError(code, t))
}
async function verifyTFA() {
clearError()
loading.show({
message: t('auth.signingIn')
})
@ -1066,6 +1130,7 @@ async function verifyTFA() {
* FINISH TFA SETUP
*/
async function finishSetupTFA() {
clearError()
loading.show({
message: t('auth.tfaSetupVerifying')
})
@ -1085,9 +1150,12 @@ async function finishSetupTFA() {
* CONFIRM EMAIL
*
* The press the confirm screen exists for. Nobody is signed in by it the browser reading the mail
* is not necessarily the one that registered so what follows is the login screen.
* is not necessarily the one that registered so what follows is the success screen, with the login
* form a link away. The token is spent either way, so it leaves the address bar here as it does on
* the paths through `cancelVerifyEmail()`.
*/
async function confirmEmail() {
clearError()
loading.show({
message: t('auth.verifyEmail.loading')
})
@ -1102,19 +1170,15 @@ async function confirmEmail() {
throw new Error(resp?.message || 'ERR_INVALID_VALIDATION_TOKEN')
}
loading.hide()
notify({
type: 'positive',
message: t('auth.verifyEmail.success')
})
cancelVerifyEmail()
state.verifyToken = ''
clearQueryParams(['verify'])
showSuccess(t('auth.verifyEmail.success'))
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
// -> Nothing left to press: a token that was refused is not going to be accepted on a second try
cancelVerifyEmail()
// -> After the screen change, which clears the box this is about to fill
showError(localizeError(apiErrorMessage(err), t))
}
}
@ -1144,13 +1208,19 @@ function cancelReset() {
onMounted(async () => {
/*
Before the fetch, and therefore before anything is drawn: the panel is held back until the
Both before the fetch, and therefore before anything is drawn: the panel is held back until the
strategies arrive, so setting the screen now means the right one is the FIRST to mount. Done
afterwards it would mount the login form, focus its email field and then take both away again.
afterwards it would mount the login form, focus its email field and then take both away again
and, now that a failed provider login is a box above the form rather than a toast beside it, it
would push that form down a beat after it was first painted.
Neither reads anything but the URL, so there is nothing to wait for. A fetch that then fails says
so over the top of this, which is the right way round: a login screen with no strategy behind it
is the larger problem of the two.
*/
screenFromQuery()
await fetchStrategies()
reportRedirectLoginError()
await fetchStrategies()
})
/**
@ -1165,11 +1235,7 @@ function reportRedirectLoginError() {
if (!code) {
return
}
notify({
type: 'negative',
message: t('auth.errors.loginError'),
caption: localizeError(code, t)
})
showError(t('auth.errors.loginError'), localizeError(code, t))
clearQueryParams(['error'])
}

@ -6,8 +6,10 @@
class="w-loading fixed inset-0 z-[8000] flex flex-col items-center justify-center bg-black/75"
role="status"
aria-live="polite"
:aria-label="t('common.loading')">
:aria-label="content.message || t('common.loading')">
<w-spinner size="32px" class="text-white" />
<p v-if="content.message" class="w-loading-message">{{ content.message }}</p>
<p v-if="content.caption" class="w-loading-caption">{{ content.caption }}</p>
</div>
</transition>
</teleport>
@ -15,7 +17,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { isActive } from '@/composables/loading'
import { content, isActive } from '@/composables/loading'
import WSpinner from './WSpinner.vue'
/**
@ -23,6 +25,9 @@ import WSpinner from './WSpinner.vue'
*
* The 500ms delay before this appears lives in `composables/loading.js`, not here -- by the time
* `isActive` flips, the overlay is meant to be visible immediately.
*
* `aria-label` is the message where there is one: the generic "Loading..." would otherwise be
* announced INSTEAD of the contents, a label on a live region standing in for what it labels.
*/
// I18N
@ -31,6 +36,29 @@ const { t } = useI18n()
</script>
<style scoped>
/*
Held off the spinner and each other, and kept to a readable measure -- the overlay is the full
viewport, so an unconstrained line would run its whole width.
*/
.w-loading-message {
margin: 1.25rem 0 0;
max-width: 28rem;
padding: 0 1.5rem;
text-align: center;
font-size: 1rem;
font-weight: 500;
color: #fff;
}
.w-loading-caption {
margin: 0.5rem 0 0;
max-width: 28rem;
padding: 0 1.5rem;
text-align: center;
font-size: 0.875rem;
color: rgb(255 255 255 / 0.7);
}
.w-loading-enter-active,
.w-loading-leave-active {
transition: opacity 0.2s var(--ease-standard);

@ -1,4 +1,4 @@
import { ref } from 'vue'
import { reactive, ref } from 'vue'
/**
* Full-screen blocking loading overlay.
@ -7,9 +7,22 @@ import { ref } from 'vue'
* provided, which is all the app ever called.
*/
/** Whether the overlay is on screen. Note this goes true only after DELAY, not on `show()`. */
/** Whether the overlay is on screen. Note this goes true only after the delay, not on `show()`. */
export const isActive = ref(false)
/**
* What the overlay says under its spinner. Every call site already passes a message -- "Signing
* in...", "Installing extension...", "Login Successful! Redirecting..." -- and for a step that ends
* in a redirect rather than in a screen, that line IS the confirmation the reader gets.
*
* Deliberately not cleared by `hide()`: the overlay fades out over 200ms, and blanking the text as
* the fade starts empties the box before it goes. `show()` overwrites both fields anyway.
*/
export const content = reactive({
message: '',
caption: ''
})
/**
* Matches the `loading: { delay: 500 }` the app previously configured. Operations that finish
* inside the window show nothing at all -- without the delay, every fast request would produce a
@ -20,14 +33,40 @@ const DELAY = 500
let timer = null
export const loading = {
show() {
if (timer !== null || isActive.value) {
/**
* Show the overlay, or -- when it is already showing or pending -- change what it says.
*
* The second case is not a corner: a login shows "Signing in...", and the answer to it turns the
* same overlay into "Login Successful! Redirecting...". So a repeat call updates the text rather
* than being dropped, and it does not re-arm a pending timer either, since the wait belongs to the
* operation that started it and not to the latest thing said about it.
*
* @param message The line under the spinner
* @param caption A second, quieter line under that
* @param delay How long to wait before appearing, in ms. Zero is for a message that is the whole
* point of the overlay rather than an apology for a slow request -- there is nothing to avoid
* flashing when the text is the answer.
*/
show({ message = '', caption = '', delay = DELAY } = {}) {
content.message = message
content.caption = caption
if (isActive.value) {
return
}
timer = setTimeout(() => {
timer = null
isActive.value = true
}, DELAY)
if (delay > 0 && timer !== null) {
return
}
if (delay > 0) {
timer = setTimeout(() => {
timer = null
isActive.value = true
}, delay)
return
}
// -> Cancels the pending wait as well: asked for immediately, it appears immediately
clearTimeout(timer)
timer = null
isActive.value = true
},
hide() {

@ -173,8 +173,8 @@ async function load() {
async function install(ext) {
loading.show({
message: t('admin.extensions.installing') + '<br>' + t('admin.extensions.installingHint'),
html: true
message: t('admin.extensions.installing'),
caption: t('admin.extensions.installingHint')
})
try {
const resp = await API_CLIENT.post(`system/extensions/${ext.key}/install`, {

@ -92,6 +92,7 @@
outlined
v-model="state.config.defaultBaseURL"
dense
placeholder="https://wiki.example.com"
:aria-label="t(`admin.mail.defaultBaseURL`)" />
</w-item-section>
</w-item>

@ -89,6 +89,81 @@ useMeta({
}
}
/*
What went wrong, above the panel. Solid negative rather than a tinted box with a coloured border:
this sits on a white page in light mode and a near-black one in dark, and one surface that carries
its own foreground reads the same on both.
Mixed towards black rather than used neat, which is a departure from the `bg-negative text-white`
banners in the admin area: `--q-negative` is #f03a47, and white on it is 3.9:1 under AA for text
this size, and worse again for the caption. Mixing rather than naming a darker red keeps the hue a
re-themed site set, the same way `--color-primary-light` is built.
*/
&-error {
background-color: color-mix(in srgb, var(--color-negative) 80%, black);
color: #fff;
/* -> Top as well as bottom: this is the first thing in the panel, so with none it sits flush
against the site title above it */
margin: 1rem 0;
/*
The banner leaves its row on the default `stretch`, which is right for one carrying actions and
wrong here: the icon is 24px and the message is a 20px line, so the text column stretched to the
icon's height and drew its line at the top of it 4px high of centre, against both the icon
beside it and the box's own padding.
*/
align-items: center;
&-message {
font-weight: 500;
}
&-caption {
margin-top: 0.25rem;
font-size: 0.875rem;
color: rgb(255 255 255 / 0.8);
}
}
/*
The line under the heading on every screen of the panel "Enter your credentials", "Fill-in the
form below to create an account", and so on. Tailwind's preflight zeroes `p` margins, so without
this the subtitle sits flush against the site title above it and the first field below it, reading
as part of neither. Margins rather than padding, so two stacked lines (the 2FA setup screen)
collapse into one gap instead of doubling it.
*/
&-subtitle {
margin: 1rem 0;
}
/*
The end of a flow, in place of the form that started it. Centred rather than left-aligned like
every other screen in the panel: there is nothing to fill in here, so the checkmark and what it
says are the whole column.
*/
&-success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1rem 0 0.5rem;
&-message {
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
margin: 1rem 0 0;
}
&-caption {
margin: 0.5rem 0 0;
color: $blue-grey-5;
@at-root .body--dark & {
color: $blue-grey-4;
}
}
}
&-strategies {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(45%, 1fr));

Loading…
Cancel
Save